Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.
Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,
Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.
Lesson 6 - User Registration, flash messages, Session Cookies
Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.
Lesson 8 - Midterm exam. Prevent duplicate insert records.
Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.
Lesson 10 - Recipe API. User permissions. Custom not found page.
Lessons 11-12 - Update and delete existing Location objects.
Lessons 13-14 - Azure Email. Search, sort, filter records. Date and currency formatting. Web Sockets.
Final Exam - CRUD Events (fill in the blanks).
Not getting to - Failed login attempts. SMS messaging. Shopping Cart. Payment processing. Event Registration.
http://localhost:8080/schools/xxx
Doesn't show 404 error.
SL4FJ Logging (See EmailService)
Delete reset_token on login
Add a failed to connect toast if cannot connect to db
Sort and filter the list of schools
Add school logo, color
If the user enters an incorrect email or password N times, can their account be disabled?
Create the database entities for the intramural leagues and teams now?
Get the location's lat/lon from LocationIQ
Get weather forecast at the location's lat/lon from the OpenWeatherAPI
Upcoming Leagues & Events
Allow SCHOOL_ADMINS to reuse leagues and events each year
XSS Attacks, other OWASP
First name field
<img src="http://localhost:9999/city.png" onclick="location='http://packt.com'">
<button class="btn btn-danger" onclick="location='http://packt.com'">Click</button>
An internal server error occurred.
could not execute statement [Data truncation: Data too long for column 'first_name' at row 1]
SQL injection
Juice box app
Using a python app to make post requests to my register route - use captcha to prevent that.
File/image upload
If I make a change to the profile.html file and make a new POST request without refreshing the page, I get a "Whitelabel Error Page" saying:
```
This application has no explicit mapping for /error, so you are seeing this as a fallback.
Sun Mar 01 23:11:58 CST 2026
There was an unexpected error (type=Internal Server Error, status=500).
User not found
java.lang.RuntimeException: User not found
at org.springframework.samples.petclinic.user.ProfileController.lambda$processProfileUpdate$1(ProfileController.java:82)
```
Is there a way to customize the Whitelabel Error Page
The hierarchy of Kirkwood locations is as follows:
- Main Campus (Main Parent)
-- Johnson Hall (Child of Main Campus)
-- Michael J Gould Rec Center (Child of Main Campus)
--- Basketball Court 1 (Child of Michael J Gould Rec Center)
--- Basketball Court 2 (Child of Michael J Gould Rec Center)
It is possible to format the table and the select menu to display in an ordered hierarchy.
To achieve this, we can write a recursive algorithm that flattens the parent-child relationships into a sorted list, while tracking the "depth" of each location.
By adding this logic directly to the School entity, both the table and your dropdown menu can access the ordered data without needing to change your repository or database queries.
We need a place to store the depth calculation (for the table padding) and the hyphenated string (for the dropdown). We use the @Transient annotation to tell Hibernate to hold this data in memory for the UI, but never attempt to save it to the database.
Open Location.java and add these two variables. (Since we are using Lombok's @Getter and @Setter annotations, you do not need to manually write the get/set methods).
import jakarta.persistence.Transient;
// ... your existing @Column code ...
// --- NEW UI HELPER FIELDS ---
@Transient
private int depth;
@Transient
private String hierarchyName;
public enum LocationStatus {
DRAFT, ACTIVE, CLOSED, COMING_SOON;
}In your School.java file, we will add a new @Transient method that reads the flat list of locations, finds the "roots" (locations with no parent), and recursively digs down to build a sorted, flattened tree.
Add this code below your existing addLocation method:
import jakarta.persistence.Transient;
import java.util.Comparator;
// ... your existing code ...
public void addLocation(Location location) {
location.setSchool(this);
getLocations().add(location);
}
// --- NEW RECURSIVE SORTING LOGIC ---
@Transient
public List<Location> getSortedHierarchy() {
List<Location> sortedHierarchy = new ArrayList<>();
List<Location> allLocations = getLocations();
// 1. Find all root locations (those without a parent) and sort them alphabetically
List<Location> roots = allLocations.stream()
.filter(loc -> loc.getParentLocation() == null)
.sorted(Comparator.comparing(Location::getName))
.toList();
// 2. Start the recursive tree build for each root
for (Location root : roots) {
buildTree(root, allLocations, 0, sortedHierarchy);
}
return sortedHierarchy;
}
private void buildTree(Location current, List<Location> allLocations, int depth, List<Location> sortedHierarchy) {
// Set the transient UI fields
current.setDepth(depth);
String prefix = "-".repeat(depth);
current.setHierarchyName((depth > 0 ? prefix + " " : "") + current.getName());
sortedHierarchy.add(current);
// Find all children of the current location and sort them alphabetically
List<Location> children = allLocations.stream()
.filter(loc -> loc.getParentLocation() != null && loc.getParentLocation().getId().equals(current.getId()))
.sorted(Comparator.comparing(Location::getName))
.toList();
// Recurse into each child
for (Location child : children) {
buildTree(child, allLocations, depth + 1, sortedHierarchy);
}
}Open LocationController.java. Update your populateParentLocations method to call the new getSortedHierarchy() method instead of the raw getLocations() list.
@ModelAttribute("parentLocations")
public List<Location> populateParentLocations(School school, @PathVariable(required = false) Integer locationId) {
// Use the newly sorted hierarchy
return school.getSortedHierarchy().stream()
.filter(loc -> loc.getId() != null)
.filter(loc -> locationId == null || !loc.getId().equals(locationId))
.toList();
}Standard HTML <select> menus do not support custom CSS padding on their internal <option> tags across all web browsers. Therefore, we must rely on the hyphenated string (hierarchyName) we built in Java.
Open createOrUpdateLocationForm.html and change th:text="${loc.name}" to th:text="${loc.hierarchyName}":
<option th:each="loc : ${parentLocations}"
th:value="${loc.id}"
th:text="${loc.hierarchyName}"
th:data-address="${loc.address ?: ''}"
th:data-lat="${loc.latitude ?: ''}"
th:data-lng="${loc.longitude ?: ''}">Parent Name</option>HTML Tables support full CSS. Rather than printing hyphens, we can use the integer depth to apply an elegant CSS padding-left to the table cell, physically indenting the text based on its depth.
Open schoolDetails.html and update your table body:
<table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
<tbody>
<tr th:each="location : ${school.sortedHierarchy}">
<td th:style="'padding-left: ' + (${location.depth} * 2) + 'rem;'">
<span th:if="${location.depth > 0}" class="text-muted me-1">↳</span>
<span th:text="${location.name}">Building Name</span>
</td>
<td th:text="${location.address}">123 Main St</td>Add this to the dependencies section of build.gradle.
implementation("com.azure:azure-communication-email:1.1.2")
Press the elephant icon to sync Gradle changes.
Open your application.properties file and add your Azure credentials. This prevents you from hardcoding sensitive information directly into your Java files.
azure.communication.connection-string=${AZURE_COMM_CONNECTION}
azure.communication.sender-email=DoNotReply@your-unique-domain.azurecomm.net
Add your AZURE_COMM_CONNECTION environment variable.
Here is a visualization of how these two new columns power the security of the reset process.
sequenceDiagram
participant U as User
participant C as AuthController
participant DB as User Database
participant E as Azure Email
%% Forgot Password Phase
U->>C: POST /forgot-password (Email)
C->>DB: Find User by Email
alt User Exists
C->>C: Generate UUID Token
C->>DB: Save reset_token & expires_at (+15 mins)
C->>E: Send Email with ?token=UUID
end
C-->>U: "Check your email" (Always returns this)
%% Reset Phase
U->>C: GET /reset-password?token=UUID (Clicks Link)
C->>DB: Find User by reset_token
DB-->>C: Returns User Data
C->>C: Check if current time < expires_at
alt Token Valid
C-->>U: Show New Password Form
else Token Expired / Invalid
C-->>U: Redirect to Login with Error
end
%% Final Submission
U->>C: POST /reset-password (Token + New Password)
C->>DB: Find User by reset_token
C->>C: Hash New Password
C->>DB: Update password_hash
C->>DB: Set reset_token & expires_at to NULL
C-->>U: Success! Redirect to LoginOpen your loginForm.html file and add the "Forgot Password" link right next to your registration link.
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Log In</button>
</div>
<div class="text-center mt-3">
<p><a th:href="@{/forgot-password}">Forgot your password?</a></p>
<p>Don't have an account? <a th:href="@{/register-student}">Register here</a></p>
</div>.requestMatchers(
"/",
"/register-student",
"/resources/**",
"/recipes/**",
"/recipes/new",
"/owners/**",
"/pets/**",
"/vets/**",
"/vets.html",
"/forgot-password",
"/reset-password"
).permitAll()Allow GET and POST requests to "/forgot-password" and "/reset-password"
Add a GET route to display a form asking for the user's email address.
@GetMapping("/forgot-password")
public String showForgotPasswordForm() {
return "auth/forgotPasswordForm";
}Create a new file named forgotPasswordForm.html in your src/main/resources/templates/auth/ directory.
Since this specific form only sends a single email string rather than a complete Java object, it is easier to use standard HTML inputs instead of the Thymeleaf inputField fragment, which expects a full object to bind to.
The required and type="email" attributes ensure the browser validates the email format before it even hits your server, saving unnecessary backend processing.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'login')}">
<body>
<h2 class="text-center">Forgot Password</h2>
<div class="container mt-4" style="max-width: 500px;">
<div th:if="${message}" class="alert alert-success" th:text="${message}">
Message goes here.
</div>
<div th:if="${error}" class="alert alert-danger" th:text="${error}">
Error goes here.
</div>
<p class="text-muted text-center mb-4">
Enter your email address below and we will send you a link to reset your password.
</p>
<form th:action="@{/forgot-password}" method="post" class="form-horizontal">
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input type="email" id="email" name="email" class="form-control" required autofocus />
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Send Reset Link</button>
</div>
<div class="text-center mt-3">
<p><a th:href="@{/login}">Back to Login</a></p>
</div>
</form>
</div>
</body>
</html>When building the reset link, to handle a dynamic base URL (localhost vs. production) cleanly, the best practice in Spring Boot is to use your application.properties file.
Open your src/main/resources/application.properties file and add this line for your local development environment:
app.base-url=http://localhost:8080
When you deploy to Azure, you need to set an environment variable named APP_BASE_URL with the value https://my-project-name.azurecontainerapps.io.
Spring Boot is smart enough to automatically override the application.properties value with the environment variable during production.
Create a new file named EmailService.java in the same package as your AuthController.
This class uses the @Service annotation so Spring Boot can manage it and inject it into your AuthController. The constructor uses the @Value annotation to pull your credentials from the properties file and initialize the Azure EmailClient.
package org.springframework.samples.petclinic.user;
import com.azure.communication.email.EmailClient;
import com.azure.communication.email.EmailClientBuilder;
import com.azure.communication.email.models.EmailMessage;
import com.azure.communication.email.models.EmailSendResult;
import com.azure.core.util.polling.SyncPoller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
private final EmailClient emailClient;
private final String senderEmail;
public EmailService(
@Value("${azure.communication.connection-string}") String connectionString,
@Value("${azure.communication.sender-email}") String senderEmail) {
// Initialize the client once when the application starts
this.emailClient = new EmailClientBuilder()
.connectionString(connectionString)
.buildClient();
this.senderEmail = senderEmail;
}
public void sendPasswordResetEmail(String toAddress, String resetLink) {
String subject = "Password Reset Request";
String htmlContent = "<html><body>"
+ "<h2>Password Reset</h2>"
+ "<p>We received a request to reset your password. Click the link below to set a new password:</p>"
+ "<p><a href=\"" + resetLink + "\">Reset Password</a></p>"
+ "<p>If you did not request this, please ignore this email.</p>"
+ "</body></html>";
String plainTextContent = "Please reset your password using this link: " + resetLink
+ "\n\nIf you did not request this, please ignore this email.";
EmailMessage message = new EmailMessage()
.setSenderAddress(this.senderEmail)
.setToRecipients(toAddress)
.setSubject(subject)
.setBodyHtml(htmlContent)
.setBodyPlainText(plainTextContent);
try {
// Send the email and wait for the operation to complete
SyncPoller<EmailSendResult, EmailSendResult> poller = emailClient.beginSend(message, null);
poller.waitForCompletion();
} catch (Exception e) {
// Log the error. In a production environment, use a logger like SLF4J.
System.err.println("Failed to send email to " + toAddress + ": " + e.getMessage());
}
}
}
Before we implement the methods to your controller, we need to add a method to the UserRepository so it knows how to find a user by their token.
Spring Data JPA will automatically translate this method name into SELECT * FROM users WHERE reset_token = ?
package org.springframework.samples.petclinic.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
// Add this new method
Optional<User> findByResetToken(String resetToken);
}
Open your AuthController.java file and add a POST route to process the form.
Run the program, submit the "Forgot Password" form withhat a user that has an email address that you have access to.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.time.LocalDateTime;
import java.util.UUID;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final EmailService emailService; // Your Azure email service
// Injects the URL from application.properties, defaults to localhost if missing
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
// Add to Constructor
public AuthController(UserService userService, SchoolRepository schoolRepository,
AuthenticationManager authenticationManager,
UserRepository userRepository, EmailService emailService) {
this.userService = userService;
this.schoolRepository = schoolRepository;
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.emailService = emailService;
}
// -- Other code --
@PostMapping("/forgot-password")
public String processForgotPassword(@RequestParam("email") String email, RedirectAttributes redirectAttributes) {
Optional<User> userOptional = userRepository.findByEmail(email);
if (userOptional.isPresent()) {
User user = userOptional.get();
// 1. Generate a secure token
String token = UUID.randomUUID().toString();
// 2. Save this token and an expiration timestamp (15 minutes from now)
user.setResetToken(token);
user.setResetTokenExpiresAt(LocalDateTime.now().plusMinutes(15));
userRepository.save(user);
// 3. Build the dynamic reset link
String resetLink = baseUrl + "/reset-password?token=" + token;
// 4. Use your Azure service to email the link
// Assuming your service has a method like this:
emailService.sendPasswordResetEmail(user.getEmail(), resetLink);
}
// Always return a generic success message to prevent "email enumeration" security risks.
// (Hackers can't use this form to guess which emails are registered in your database).
redirectAttributes.addFlashAttribute("messageSuccess", "If an account with that email exists, a password reset link has been sent.");
return "redirect:/login";
}To make this controller work, your database needs needs to temporarily remember the unique token and explicitly enforce an expiration time (usually 15 to 60 minutes).
Here is how to update your schema to add the token tracking columns to your existing MySQL database. Adding an index to the token column is highly recommended, as your AuthController will need to look up users by this exact string when they click the link in their email.
-- Add the token and expiration columns
ALTER TABLE users ADD COLUMN reset_token VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN reset_token_expires_at DATETIME NULL;
-- Add an index for fast lookups during the reset phase
CREATE UNIQUE INDEX idx_users_reset_token ON users (reset_token);Here is how to update your Java entity to add the corresponding fields to map the new columns.
By explicitly setting the reset_token and reset_token_expires_at to null the moment the password is successfully changed, you ensure that the emailed link instantly becomes a dead link, preventing re-uses or exploits.
@Column(name = "reset_token", unique = true)
private String resetToken;
@Column(name = "reset_token_expires_at")
private LocalDateTime resetTokenExpiresAt;
// Optional helper methods for cleaner controller logic
public boolean isResetTokenValid() {
return this.resetTokenExpiresAt != null && LocalDateTime.now().isBefore(this.resetTokenExpiresAt);
}
public void clearResetToken() {
this.resetToken = null;
this.resetTokenExpiresAt = null;
}Add a GET and POST route to display and process a form asking for the user's new password.
@GetMapping("/reset-password")
public String showResetPasswordForm(@RequestParam("token") String token, Model model, RedirectAttributes redirectAttributes) {
// TODO: Look up the user by the provided token
// If the token is invalid or expired, redirect to the login page with a flash message.
// If valid, pass the token to the view so the form can submit it back
return "auth/resetPasswordForm";
}
@PostMapping("/reset-password")
public String processResetPassword(@RequestParam("token") String token,
@RequestParam("password") String newPassword,
RedirectAttributes redirectAttributes) {
// TODO: Look up the user by the token
// 1. Hash the newPassword using your PasswordEncoder
// 2. Clear the reset token from the database so it cannot be used again
// 3. Update the user's password and token in the database
return "redirect:/login";
}Open your AuthController.java file and replace your existing placeholder method with this complete implementation that will be called when a user clicks the link in their email.
This code will catch the token from the URL (?token=ed2a...), look it up in the database, and use the isResetTokenValid() helper method we added to your User entity to ensure the 15-minute window hasn't expired.
@GetMapping("/reset-password")
public String showResetPasswordForm(@RequestParam("token") String token, Model model, RedirectAttributes redirectAttributes) {
Optional<User> userOptional = userRepository.findByResetToken(token);
if (userOptional.isEmpty() || !userOptional.get().isResetTokenValid()) {
redirectAttributes.addFlashAttribute("messageDanger", "Your password reset link is invalid or has expired. Please request a new one.");
return "redirect:/login";
}
// We will put this in a hidden <input> field on the next HTML page.
model.addAttribute("token", token);
return "auth/resetPasswordForm";
}Create a new file named resetPasswordForm.html in your src/main/resources/templates/auth/ directory.
Since this form only sends a single password string rather than a complete Java object, it is easier to use standard HTML inputs instead of the inputField fragment, which expects a full object.
The input tag's pattern attribute ensures the browser validates the format before submitting to your server.
If the password reset token hasn't expired, this form will send the new password, along with the secure token, back to your server.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'login')}">
<body>
<h2 class="text-center">Set New Password</h2>
<div class="container mt-4" style="max-width: 500px;">
<p class="text-muted text-center mb-4">
Please enter your new password below.
</p>
<form th:action="@{/reset-password}" method="post" class="form-horizontal">
<input type="hidden" name="token" th:value="${token}" />
<div class="mb-3">
<label for="password" class="form-label">New Password</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
minlength="8"
pattern="^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$"
title="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number."
autofocus />
<div class="form-text">
Must be at least 8 characters and contain an uppercase letter, a lowercase letter, and a number.
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Update Password</button>
</div>
</form>
</div>
</body>
</html>When the user clicks "Update Password," the form submits the hidden token and the new password via a POST request.
The implementation in the final method in your AuthController hashes the new password and clears the token so the link becomes invalid.
You will need to inject your PasswordEncoder into your controller's constructor alongside the others.
import org.springframework.security.crypto.password.PasswordEncoder;
// ... Other imports ...
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
// Add to Constructor
public AuthController(UserService userService, SchoolRepository schoolRepository,
AuthenticationManager authenticationManager,
UserRepository userRepository, EmailService emailService,
PasswordEncoder passwordEncoder) {
this.userService = userService;
this.schoolRepository = schoolRepository;
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.emailService = emailService;
this.passwordEncoder = passwordEncoder;
}
// ... Other methods ...
@PostMapping("/reset-password")
public String processResetPassword(@RequestParam("token") String token,
@RequestParam("password") String newPassword,
RedirectAttributes redirectAttributes) {
Optional<User> userOptional = userRepository.findByResetToken(token);
// Perform a final security check in case the token expired while they were typing
if (userOptional.isEmpty() || !userOptional.get().isResetTokenValid()) {
redirectAttributes.addFlashAttribute("error", "Your session expired. Please request a new link.");
return "redirect:/login";
}
User user = userOptional.get();
user.setPassword(passwordEncoder.encode(newPassword));
user.clearResetToken();
userRepository.save(user);
redirectAttributes.addFlashAttribute("messageSuccess", "Your password has been reset. Please log in.");
return "redirect:/login";
}Once this is saved, your complete end-to-end password reset flow should be functional. The user can request a link, receive the email, click the tokenized URL, and securely update their credentials.
To inject an environment variable into your Azure Container App, append the --set-env-vars parameter to your existing command.
az containerapp update `
--name <your-container-app-name> `
--resource-group <your-resource-group-name> `
--image <your-docker-username>/<your-imagename>:<your-version> `
--set-env-vars AZURE_COMM_CONNECTION=<your-azure-communication-connection-string>
DROP TABLE IF EXISTS leagues;
CREATE TABLE leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
location_id INT, -- Default location
user_id INT, -- League Manager/Contact
name VARCHAR(255) NOT NULL,
description TEXT,
registration_start DATETIME,
registration_end DATETIME,
league_start DATETIME,
league_end DATETIME,
is_public TINYINT DEFAULT 1,
type ENUM('male', 'female', 'coed') NOT NULL,
capacity INT,
capacity_type ENUM('team', 'individual') NOT NULL,
fee DECIMAL(6,2),
status_id ENUM('draft', 'active', 'inactive', 'postponed', 'cancelled', 'past') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Performance: Quickly find active leagues for a specific school
INDEX idx_leagues_school_status (school_id, status_id)
);
DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
captain_user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
logo_url VARCHAR(255),
status_id ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Performance: List all teams in a league
INDEX idx_teams_league (league_id)
);
DROP TABLE IF EXISTS team_users;
CREATE TABLE team_users (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('member', 'captain') DEFAULT 'member',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: A user cannot join the same team twice
UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);DROP TABLE IF EXISTS events;
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
location_id INT,
user_id INT, -- Event organizer/referee
name VARCHAR(255) NOT NULL,
description TEXT,
event_start DATETIME,
event_end DATETIME,
status_id ENUM('draft', 'active', 'ongoing', 'postponed', 'cancelled', 'final') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Integrity & Performance: Check for double-bookings at a location
INDEX idx_events_location_time (location_id, event_start)
);
DROP TABLE IF EXISTS matches;
CREATE TABLE matches (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
home_team_id INT,
away_team_id INT,
winner_team_id INT,
home_score INT DEFAULT 0,
away_score INT DEFAULT 0,
status ENUM('scheduled', 'in_progress', 'final', 'forfeit') DEFAULT 'scheduled',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,
-- Performance: Essential for calculating standings (W/L records)
INDEX idx_matches_home (home_team_id),
INDEX idx_matches_away (away_team_id)
);DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id_from INT NOT NULL,
user_id_to INT,
league_id INT,
event_id INT,
parent_message_id INT, -- For threaded replies
message VARCHAR(255) NOT NULL,
is_flagged TINYINT DEFAULT 0,
status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,
-- Performance: Quickly load chat history for a league or event
INDEX idx_messages_context (league_id, event_id)
);
DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
message_id INT NOT NULL,
user_id INT NOT NULL,
reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: One reaction per user per message
UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.
This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.
Here is how that transition works:
Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.
React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.
https://github.com/spring-petclinic/spring-petclinic-rest
A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.
If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:
// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }
// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
Vets vets = new Vets();
vets.getVetList().addAll(this.vetRepository.findAll());
return vets;
}The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."
A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.
To fully replace Thymeleaf, you would:
Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).
Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.
Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.
Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.
Maven Run Configuration
You can save this command as a clickable button within the IDE:
App Run (Maven).spring-boot:run