Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
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.
Lesson 11 - Update and delete existing objects. Filtering and limiting records by category.
Lesson 12 - Event Registration, project-specific features
Lesson 13 -
Lesson 14 - Email and SMS messaging. Failed login attempts. Web Sockets. Date and currency formatting. Shopping Cart. Payment processing.
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
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
Azure Core Services
https://www.cbtnuggets.com/it-training/microsoft-azure/core-services-overview
Azure Fundamentals AZ-900
Azure Developer Associate AZ-204
https://www.cbtnuggets.com/it-training/microsoft-azure/developer-associate
Azure Infrastructure Solutions AZ-305
https://www.cbtnuggets.com/it-training/microsoft-azure/az-305
AWS Cloud Practitioner
https://aws.amazon.com/certification/certified-cloud-practitioner/
https://www.cbtnuggets.com/it-training/aws/cloud-practitioner
AWS Associate Developer
https://aws.amazon.com/certification/certified-developer-associate/
https://www.cbtnuggets.com/it-training/aws/developer-associate-aws
AWS Certified Solutions Architect
https://aws.amazon.com/certification/certified-solutions-architect-professional/
https://www.cbtnuggets.com/it-training/aws/solutions-architect-associate
Cloud Digital Leader
https://www.skills.google/paths/9
https://www.cbtnuggets.com/it-training/google-cloud/cloud-digital-leader
https://cloud.google.com/learn/certification/cloud-digital-leader/
Associate Cloud Engineer
https://www.skills.google/paths/11
https://www.cbtnuggets.com/it-training/google-cloud/associate-cloud-engineer
https://cloud.google.com/learn/certification/cloud-engineer/
Professional Cloud Developer
https://www.skills.google/paths/19
https://cloud.google.com/learn/certification/cloud-developer/
When NOT logged in:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "Join the Action" with a "Login" and "Register" button on the right side.
Accessing a school by id is currently not allowed because of this line in the SecurityConfig file:
.requestMatchers(HttpMethod.GET, "/schools", "/schools/{slug:[a-zA-Z-]+}").permitAll()
Accessing "/schools/new" will redirect you to the login page.
Accessing "/users/profile" will redirect you to the login page.
Accessing "/xxx" will display the custom 404 page defined in the "system.GlobalExceptionHandler" class
Accessing "/recipes" will display the recipe JSON data
Accessing any route with a slash at the end will redirect without a slash.
When logged in as a STUDENT:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.
Trying to access "/schools/new" will display a "Forbidden" error message.
This page can be customized by editing the "system.GlobalExceptionHandler" class and creating an "error/403.html" file.
Trying to access "/users/profile" will display the "Edit Profile" page.
When logged in as a SCHOOL_ADMIN:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.
We will change this week to allow the user to C/R/U/D location data.
Trying to access "/schools/new" will display a "Forbidden" error message.
Trying to access "/users/profile" will display the "Edit Profile" page.
When logged in as a SUPER_ADMIN:
"/schools" displays the list of schools WITH the "Add School" button.
Accessing "/schools/new" will display the form and allow submissions.
Note this line of code in the SecurityConfig file:
.requestMatchers("/schools/new")
.hasAuthority("MANAGE_ALL_SCHOOLS")
Note the use of .permitAll(), .hasAuthority(), and .authenticated()
Trying to access "/users/profile" will display the "Edit Profile" page.
Whenever you build a new private page (like an admin dashboard or a user's team settings), you must remember to add an explicit .requestMatchers(...).hasAuthority() rule for it in this config.
Running the UserDetailsServiceImplTest.loadUserByUsername
method fails saying "the return value of "Role.getPermissions()" is null".
Last week when we updated UserDetailsServiceImpl, we told it to look inside every Role and loop through its Permissions.
However, in your test file, we are currently creating a mock studentRole but aren't giving it a list of permissions.
To fix this, we just need to initialize an empty (or populated) Set of permissions for that role in your @BeforeEach setup method.
package org.springframework.samples.petclinic.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserDetailsServiceImplTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserDetailsServiceImpl userDetailsService;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setEmail("example-student@kirkwood.edu");
testUser.setPassword("hashedPassword");
Role studentRole = new Role();
studentRole.setName("STUDENT");
// Initialize a permission and add it to the role
Permission viewLeaguesPermission = new Permission();
viewLeaguesPermission.setName("VIEW_LEAGUES");
studentRole.setPermissions(Set.of(viewLeaguesPermission));
testUser.setRoles(Set.of(studentRole));
}
@Test
void loadUserByUsername() {
// Arrange
when(userRepository.findByEmail(testUser.getEmail())).thenReturn(Optional.of(testUser));
// Act
UserDetails userDetails = userDetailsService.loadUserByUsername(testUser.getEmail());
// Assert
assertNotNull(userDetails);
assertEquals(testUser.getEmail(), userDetails.getUsername());
assertEquals(testUser.getPassword(), userDetails.getPassword());
// Check that the ROLE was loaded correctly (Requires "ROLE_" prefix)
assertTrue(userDetails.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_STUDENT")));
// Check that the PERMISSION was loaded correctly (No prefix)
assertTrue(userDetails.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("VIEW_LEAGUES")));
verify(userRepository, times(1)).findByEmail(testUser.getEmail());
}
}The SchoolControllerTest.testProcessCreationFormSuccess
method fails with an error saying "Error creating bean with name 'schoolController'"
When we updated your SchoolController to fetch the logged-in student, we added UserRepository to its constructor.
Add the UserRepository to your class variables and annotate it with @MockitoBean.
import org.springframework.samples.petclinic.user.UserRepository;
// ... your other imports ...
@WebMvcTest(SchoolController.class)
@Import(SecurityConfig.class)
class SchoolControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private SchoolRepository schools;
@MockitoBean
private UserRepository userRepository;
private School school;
// ...Also, now that we locked down /schools/new so only users with the MANAGE_SCHOOLS authority can access it, your tests are going to fail because the test runner is acting as an anonymous guest.
@WebMvcTest is a "sliced" test that only loads your SchoolController and basic Spring MVC components. It ignores all of your @Configuration classes. Because your SecurityConfig was left out of the test environment, Thymeleaf has no idea how to process Spring Security tags like sec:authorize, so it throws that TemplateProcessingException.
The @Import annotation forces the test to load your custom security rules and the Thymeleaf security dialect.
import org.springframework.samples.petclinic.user.SecurityConfig;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
// ... your other imports ...
@WebMvcTest(SchoolController.class)
@Import(SecurityConfig.class)
class SchoolControllerTest {
// ...
@MockitoBean
private UserDetailsService userDetailsService;
@MockitoBean
private AuthenticationConfiguration authenticationConfiguration;
// ...When you run your application normally, Spring Boot's background auto-configuration provides the HttpSecurity builder for you. However, because @WebMvcTest strips that away, your SecurityConfig is left standing there asking for an HttpSecurity object that no longer exists.
Open your SecurityConfig.java file and add the @EnableWebSecurity annotation right below your @Configuration annotation.
Running the SchoolControllerTest.testShowSchoolList method should now pass.
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
// ... your other imports ...
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ... your existing beans ...
}Back in SchoolControllerTest, we need to add a Security MockMvc Request Post-Processor to allow you to fake a logged-in user with specific authorities just for the duration of a test. It forces the authentication token directly into the exact HTTP request being performed.
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// ... your other imports ...
@Test
@DisplayName("User clicks \"Add School\" -> GET /schools/new")
void testInitCreationForm() throws Exception {
mockMvc.perform(get("/schools/new")
// 1. INJECT THE SUPER_ADMIN PRIVILEGE DIRECTLY INTO THE REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().isOk())
.andExpect(view().name("schools/createOrUpdateSchoolForm"))
.andExpect(model().attributeExists("school"));
}
@Test
@DisplayName("Validation Passed -> verify that the controller tells the repository to save() the school and then redirects us.")
void testProcessCreationFormSuccess() throws Exception {
mockMvc.perform(post("/schools/new")
.param("name", "University of Iowa")
.param("domain", "uiowa.edu")
// 2. INJECT IT INTO THE POST REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/schools"));
verify(schools).save(any(School.class));
}
@Test
@DisplayName("Validation Failed -> send an empty domain and ensure the controller returns us to the form instead of saving.")
void testProcessCreationFormHasErrors() throws Exception {
mockMvc.perform(post("/schools/new")
.param("name", "Bad School")
.param("domain", "")
// 3. INJECT IT INTO THE FAILED POST REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().isOk())
.andExpect(model().attributeHasErrors("school"))
.andExpect(model().attributeHasFieldErrors("school", "domain"))
.andExpect(view().name("schools/createOrUpdateSchoolForm"));
}Back in week 3, I asked you to remove some lines from the CrashControllerIntegrationTests class.
Add those lines back and drop the following dummy security bean inside it.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
// ... your other imports ...
class CrashControllerIntegrationTests {
// code omitted
@Test
void testTriggerExceptionHtml() {
// code omitted
}
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
static class TestConfiguration {
// ADD THIS: A dummy security configuration just for this test
@Bean
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
return http.build();
}
}
}The CrashControllerIntegrationTests.testTriggerExceptionJson method is failing with an error saying: "Caused by: HttpMessageNotReadableException: JSON parse error: Unrecognized token 'An'.
Instead of letting Spring Boot generate the standard JSON error map that the test expects, the GlobalExceptionHandler is catching the exception and returning a plain text string that starts with "An unexpected error occurred...". When Jackson tries to parse that plain string as a JSON Map, it fails on the first word ("An").
The cleanest solution is to tell this specific isolated test environment to completely ignore all custom exception handlers. You can do this by adding a @ComponentScan exclusion filter to the nested TestConfiguration class.
import org.springframework.context.annotation.ComponentScan;
// -- Other imports --
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
// ADD THIS: Instructs the test context to ignore all custom global exception handlers
@ComponentScan(excludeFilters = @ComponentScan.Filter(
type = org.springframework.context.annotation.FilterType.ANNOTATION,
classes = org.springframework.web.bind.annotation.ControllerAdvice.class))
static class TestConfiguration {The MySqlIntegrationTests.testSchoolDetails method fails saying
java.lang.AssertionError:
Expecting actual:
"<!DOCTYPE html>...to contain: "Kirkwood Community College"
Either change RequestEntity.get("/schools/1") to RequestEntity.get("/schools/kirkwood")
or
You need to add the numeric ID pattern to your requestMatchers list in your SecurityConfig.java file . Additionally, you should update the slug pattern in this configuration to match an updated regex to be applied soon to the SchoolController, otherwise schools with numbers in their name (like "kirkwood2") will also be blocked by Spring Security.
.requestMatchers(HttpMethod.GET,
"/schools",
"/schools/{schoolId:\\d+}",
"/schools/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}").permitAll()The PetControllerTests currently fail because our SecurityConfig doesn't allow those requests.
.requestMatchers(
"/",
"/register-student",
"/resources/**",
"/recipes/**",
"/recipes/new",
"/owners/**",
"/pets/**",
"/vets/**",
"/vets.html"
).permitAll()The routes will work, but the tests will still fail because of how the @WebMvcTest annotation behaves. Remember, it creates a "sliced" application context, meaning it only loads the specified controller (PetController) and ignores your custom @Configuration classes—including your SecurityConfig.
Because your SecurityConfig is missing from the test environment, Spring Boot applies its strict default security settings blocking all endpoints (returning 401 Unauthorized for your GET requests) and requires a CSRF token for all data submissions (returning 403 Forbidden for your POST requests).
The most direct solution is to instruct the mock environment to disable its web filters. This bypasses the default security layer and allows the tests to evaluate the controller logic directly.
Add the @AutoConfigureMockMvc(addFilters = false) annotation to your class header:
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
// -- Other imports --
@WebMvcTest(value = PetController.class,
includeFilters = @ComponentScan.Filter(value = PetTypeFormatter.class, type = FilterType.ASSIGNABLE_TYPE))
@AutoConfigureMockMvc(addFilters = false) // <-- Add this line
@DisabledInNativeImage
@DisabledInAotMode
class PetControllerTests {
// ... all existing setup and test methods remain unchanged ...
}The testInitCreationForm method fails saying: `TemplateProcessingException: Exception evaluating SpringEL expression: "opt.key" (template: "fragments/selectField" - line 13, col 15)`.
The original PetClinic fragment was built assuming every select menu would be populated by a List of custom objects (like PetType). However, your user profile controller is passing a Map for the languages.
To resolve this, we can use Thymeleaf's th:if and th:unless attributes to create two separate, isolated blocks of HTML. This guarantees Thymeleaf will never attempt to read a .key property unless it is certain the object is a Map.
<select class="form-select"
th:classappend="${valid ? '' : 'is-invalid'}"
th:field="*{__${name}__}"
th:id="${name}">
<th:block th:if="${options instanceof T(java.util.Map)}">
<option th:each="opt : ${options}"
th:value="${opt.key}"
th:text="${opt.value}">Option</option>
</th:block>
<th:block th:unless="${options instanceof T(java.util.Map)}">
<option th:each="opt : ${options}"
th:value="${opt}"
th:text="${opt}">Option</option>
</th:block>
</select>If I am logged in with a "kirkwood.edu" email address, I want to see my player card on "/schools/kirkwood", but not "/schools/uiowa".
If the user's email address doesn't match the current school, we can update the schoolDetails.html file have an alternate block that says something like "You must have an active uiowa.edu email address to register for University of Iowa intramurals."
We can use Thymeleaf's built-in #strings.endsWith() utility method to compare the end of the user's email address against the specific school's domain dynamically.
Update the right column in your schools/schoolDetails.html file to look like this:
<div class="col-lg-3">
<div th:if="${currentUser == null}" class="card shadow-sm">
<div class="card-body text-center">
<h5 class="card-title">Join the Action</h5>
<p class="card-text text-sm">Log in or register to join teams and view your player stats.</p>
<a th:href="@{/login}" class="btn btn-primary w-100 mb-2">Log In</a>
<a th:href="@{/register-student}" class="btn btn-outline-secondary w-100">Register</a>
</div>
</div>
<div th:if="${currentUser != null and !#strings.endsWith(currentUser.email, '@' + school.domain) and !#strings.endsWith(currentUser.email, '.' + school.domain)}" class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">Access Restricted</h5>
</div>
<div class="card-body text-center">
<i class="fa fa-exclamation-triangle text-warning mb-2" style="font-size: 2rem;"></i>
<p class="card-text text-sm"
th:text="'You must have an active ' + ${school.domain} + ' email address to register for ' + ${school.name} + ' intramurals.'">
</p>
</div>
</div>
<div th:if="${currentUser != null and (#strings.endsWith(currentUser.email, '@' + school.domain) or #strings.endsWith(currentUser.email, '.' + school.domain))}" class="card shadow-sm border-primary">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">My Player Card</h5>
<a th:href="@{/users/profile}" class="btn btn-sm btn-light">Edit</a>
</div>
<div class="card-body">
<th:block th:if="${!#strings.isEmpty(currentUser.firstName) or !#strings.isEmpty(currentUser.nickname)}">
<h5 class="card-title text-primary">
<span th:if="${!#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.nickname}">Nickname</span>
<span th:if="${#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.firstName + (!#strings.isEmpty(currentUser.lastName) ? ' ' + currentUser.lastName : '')}">First Last</span>
</h5>
<hr>
</th:block>
<div class="mb-3 text-sm">
<strong>Email:</strong> <br>
<span th:text="${currentUser.email}">student@school.edu</span>
<span th:if="${currentUser.publicEmail}" class="badge bg-success float-end">Public</span>
</div>
<div class="mb-3 text-sm" th:if="${currentUser.phone}">
<strong>Phone:</strong> <br>
<span th:text="${currentUser.phone}">(319) 555-0199</span>
<span th:if="${currentUser.publicPhone}" class="badge bg-success float-end">Public</span>
</div>
<div class="text-sm">
<strong>Language:</strong>
<span th:switch="${currentUser.preferredLanguage}">
<span th:case="'EN'">English</span>
<span th:case="'ES'">Spanish</span>
<span th:case="'KO'">Korean</span>
<span th:case="*">Not specified</span>
</span>
</div>
</div>
</div>
</div>The string concatenation '@' + school.domain ensures that a user with the email admin@uiowa.edu is not accidentally granted access to a school domain like iowa.edu.
the logic needs to check for two valid conditions:
The email ends with @kirkwood.edu (usually faculty or staff).
The email ends with .kirkwood.edu (usually students on a subdomain).
Adding the . check accurately catches the subdomain without accidentally granting access to spoofed domains (like hacker@badkirkwood.edu).
Since all mappings in the SchoolController starts with "/schools", update it to use a @RequestMapping annotation.
@Controller
@RequestMapping("/schools")
public class SchoolController {
// Code omitted
@GetMapping
public String showSchoolList(@RequestParam(defaultValue = "1") int page, Model model) {
// Code omitted
}
@GetMapping("/new")
public String initCreationForm(Map<String, Object> model) {
// Code omitted
}
@PostMapping("/new")
public String processCreationForm(@Valid School school, BindingResult result) {
// Code omitted
}
// Matches ONLY numbers (e.g., /schools/1)
@GetMapping("/{schoolId:\\d+}")
public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
// Code omitted
}
// Matches text (e.g., /schools/kirkwood)
@GetMapping("/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
// Code omitted
}
}SCHOOL_ADMIN users with "MANAGE_FACILITIES" permissions should be able to update only their own school's info.
SUPER_ADMIN users with "MANAGE_ALL_SCHOOLS" permissions will be able to update any school's info.
Only SUPER_ADMIN users will have the ability to change a school's status from active to inactive or suspended.
Add a method to the SchoolController to determine if thecurrent user has edit privileges.
// New imports
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
// New method
private void verifyEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) ||
userEmail.endsWith("." + school.getDomain());
if (!isSuperAdmin && !(isSchoolAdmin && belongsToSchool)) {
throw new AccessDeniedException("You do not have permission to edit this school.");
}
}SCHOOL_ADMIN users with "MANAGE_FACILITIES" permissions should be able to update only their own school's info.
SUPER_ADMIN users with "MANAGE_ALL_SCHOOLS" permissions will be able to update any school's info.
Add a method to the SchoolController to determine if thecurrent user has edit privileges.
// New imports
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
// New method
private void verifyEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) ||
userEmail.endsWith("." + school.getDomain());
if (!isSuperAdmin && !(isSchoolAdmin && belongsToSchool)) {
throw new AccessDeniedException("You do not have permission to edit this school.");
}
}In your SchoolController.java, add an initUpdateForm method. They will extract the logged-in user's email and authorities to verify they have the right to edit the requested school.
@GetMapping("/{id}/edit")
public String initUpdateForm(@PathVariable("id") int id, Model model) {
School school = schoolRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
verifyEditPermissions(school);
model.addAttribute("school", school);
return "schools/createOrUpdateSchoolForm";
}Here is the updated HTML block in the schoolDetails.html page.
By adding Bootstrap's flexbox utility classes to the container, the heading and the button will automatically align to opposite sides of the screen.
<html xmlns:th="https://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<!-- Code omitted -->
<div class="mb-4 pb-2 border-bottom d-flex justify-content-between align-items-center">
<h1 class="mb-0"><span th:text="${school.name}">School Name</span> Intramurals</h1>
<a th:href="@{|/schools/${school.id}/edit|}"
sec:authorize="hasAuthority('MANAGE_FACILITIES')"
class="btn btn-outline-primary">
Edit School
</a>
</div>Only SUPER_ADMIN users will have the ability to change a school's status from active to inactive or suspended.
In createOrUpdateSchoolForm.html: Wrap the following status dropdown field so it only renders for Super Admins.
Your database and Java entity use an Enum for the school status. In Java, enum constants are strictly case-sensitive and are almost always defined in uppercase (e.g., ACTIVE, INACTIVE, SUSPENDED).
<html xmlns:th="https://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}
<!-- Code omitted -->
<div sec:authorize="hasAuthority('MANAGE_ALL_SCHOOLS')" class="mb-3">
<label for="status" class="form-label">School Status</label>
<select class="form-select" id="status" th:field="*{status}">
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
<option value="SUSPENDED">Suspended</option>
</select>
</div>The <form> tag is missing the th:action attribute. Without it, Thymeleaf does not inject the hidden CSRF (Cross-Site Request Forgery) security token. When you click submit, Spring Security sees a POST request without a token, assumes it is a malicious attack, and silently blocks the submission.
The "Add School" text (heading and button)is static. We need to add Thymeleaf conditional logic to check if the school has an ID.
<h2 th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</h2>
<form th:object="${school}" class="form-horizontal" id="add-school-form" method="post"
th:action="${school.id == null} ? @{/schools/new} : @{|/schools/${school.id}/edit|}">
<!-- code omitted -->
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button class="btn btn-primary" type="submit"
th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</button>
</div>
</div>
</form>th:field="*{status}" is a Thymeleaf attribute that binds an HTML form input directly to a specific property on your backend Java object.When you use th:field, Thymeleaf generates three standard HTML attributes for that element when the page renders:
id="status": Assigns an ID so your <label for="status"> can connect to the input.
name="status": Tells the browser what key to use when sending the submitted form data back to your Spring controller.
value="...": Automatically pre-fills the input with the current value of the object. If you are editing an existing school, it selects the correct dropdown option based on what is in the database.
*{...})The asterisk is a "selection variable expression." It is designed to be used inside a parent tag that has a th:object attribute defined.
In your createOrUpdateSchoolForm.html file, your main <form> tag likely looks like this:
<form th:object="${school}" method="post">
Because the form has already "selected" the ${school} object, any input inside that form can use the asterisk shorthand. Writing *{status} is simply a shorter, cleaner way of writing ${school.status}.
In your SchoolController.java, add a processUpdateForm method to process the form for logged-in user's that have the right to edit the requested school.
@PostMapping("/{id}/edit")
public String processUpdateForm(@Valid School school, BindingResult result, @PathVariable("id") int id) {
School existingSchool = schoolRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
verifyEditPermissions(existingSchool);
if (result.hasErrors()) {
return "schools/createOrUpdateSchoolForm";
}
// Prevent standard admins from modifying the status via form tampering
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
if (!isSuperAdmin) {
school.setStatus(existingSchool.getStatus());
}
school.setId(id);
schoolRepository.save(school);
// Strip ".edu" for the redirect to match your slug regex [a-zA-Z-]+
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}You should also update the redirect at the end of your existing processCreationForm method to use the slug format:
@PostMapping("/new")
public String processCreationForm(@Valid School school, BindingResult result) {
if (result.hasErrors()) {
return "schools/createOrUpdateSchoolForm";
}
schoolRepository.save(school);
// Update redirect to use the slug
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}Assume, you didn't capitalize the values in the select block:
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
The form would no longer submit without displaying errors.
Add this inside the form tag of the createOrUpdateSchoolForm file to display the errors.
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<p class="mb-0">Please correct the highlighted errors before submitting.</p>
<ul>
<li th:each="err : ${#fields.errors('*')}" th:text="${err}">Error message</li>
</ul>
</div>When I add a new school named "Kirkwood2" with "kirkwood2.edu" as the domain, the program redirects me to "/schools/kirkwood2" and displays a 404 error.
Your current mapping looks like this:
@GetMapping("/{slug:[a-zA-Z-]+}")
This regex explicitly tells Spring Boot to only accept URLs that contain letters (a-z, A-Z) and hyphens (-). Because "kirkwood2" contains a number, Spring rejects it as an invalid path and throws a 404.
To resolve this, you need to add numbers (0-9) to the allowed characters in your regex pattern.
@GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
See an explanation on the next slide.
To keep your clean URLs without causing conflicts between /schools/2 and /schools/kirkwood2, we need to make the slug regex slightly more specific.
We can tell Spring Boot that a slug can contain letters, numbers, and hyphens, but it must contain at least one letter or hyphen. This guarantees it will never match a purely numeric ID.
[a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the start.
[a-zA-Z-]: Requires exactly one letter or hyphen in the middle.
[a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the end
With this change, /schools/1 will route to the ID method, and /schools/kirkwood2 will route to the slug method.
When editing a school, SCHOOL_ADMIN users should be able to manage locations.
A school that is inactive or suspended will have their upcoming leagues and events on the schoolDetails page replaced with a message saying their status.
When guest user accesses "/users/profile" it correctly redirects the user to the login page but doesn't display message.
Let's make it display a warning message saying something like "You must be logged in to edit your profile"?
Because Spring Security acts as a bouncer that stands in front of your application, it intercepts the guest user and forces a redirect to the login page before your code ever has a chance to attach a standard warning message or Flash Attribute.
However, Spring Security has a brilliant hidden feature: when it intercepts a user, it secretly saves the exact URL they were trying to visit in the session so it knows where to send them after they successfully log in.
We can tap into this "Saved Request" directly inside your Login Controller to figure out where they came from and display a custom warning message!
Here is how to set it up in two easy steps.
Find the controller method that serves your custom login page (likely an @GetMapping("/login") in a UserController or LoginController).
We are going to inject the HttpSession and check for that saved request:
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.http.HttpSession;
// ... inside your controller ...
@GetMapping("/login")
public String showLoginForm(HttpSession session, Model model) {
// 1. Ask the session if Spring Security saved an intercepted request
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
// 2. If a request was saved, inspect the URL
if (savedRequest != null) {
String attemptedUrl = savedRequest.getRedirectUrl();
// 3. Customize the message based on where they were trying to go
if (attemptedUrl.contains("/users/profile")) {
model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
} else {
model.addAttribute("messageWarning", "Please log in to access that page.");
}
}
return "users/login"; // Replace with your actual login HTML path if different
}Now, you just need to ensure your login.html template has an alert box set up to catch and display that messageWarning attribute.
Add this somewhere near the top of your login form container:
Now, if a user clicks the standard "Login" button in your navbar, they will just see the normal login form. But if they try to sneak into /users/profile, the bouncer will kick them to the login page and hand them your custom warning!
Are you ready to move on to building out those SchoolController CRUD methods (Create, Read, Update, Delete) so your admins can start managing the intramural locations?
<div th:if="${messageWarning}" class="alert alert-warning alert-dismissible fade show" role="alert">
<span th:text="${messageWarning}">Please log in.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>I have an AuthController with this initLoginForm method.
```
@GetMapping("/login")
public String initLoginForm(Model model, HttpSession session) {
// Ask the session if Spring Security saved an intercepted request
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
// If a request was saved...
if (savedRequest != null) {
String attemptedUrl = savedRequest.getRedirectUrl();
// ...customize the message based on where they were trying to go
if (attemptedUrl.contains("/users/profile")) {
model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
} else {
model.addAttribute("messageWarning", "Please log in to access that page.");
}
}
User user = new User();
// Grab the failed email attempt from Spring Security's session memory
String lastEmail = (String) session.getAttribute("LAST_EMAIL");
if (lastEmail != null) {
user.setEmail(lastEmail);
session.removeAttribute("LAST_EMAIL"); // Clean up the session
}
model.addAttribute("user", user);
return "auth/loginForm";
}
```
The "You must be logged in to edit your profile." warning message displays when I visit "/users/profile". The "Please log in to access that page." warning displays when I visit "/schools/new". However, when I visit "/login" by clicking the login button, the last warning message re-appears.
Here is exactly what is happening: When Spring Security intercepts an unauthenticated user, it places that SPRING_SECURITY_SAVED_REQUEST object into their session memory. That object stays in their session until they successfully log in. If a user gets redirected to the login page, sees the warning, decides not to log in, goes back to the homepage, and then later clicks the standard "Login" button... your controller looks into the session, sees the ghost of that old saved request still sitting there, and displays the warning again!
Why we can't just delete it: Your first instinct might be to just add session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST") right after showing the message (just like you did with the LAST_EMAIL). However, if you delete it, Spring Security will forget where the user was trying to go, and won't be able to forward them to their destination after they successfully log in!
Instead of deleting the saved request, we can just drop a second, temporary string into the session to keep track of which URL we just warned them about.
If the attempted URL matches the one we already warned them about, we just skip adding the message to the model.
Update your initLoginForm method to look like this:
@GetMapping("/login")
public String initLoginForm(Model model, HttpSession session) {
// Ask the session if Spring Security saved an intercepted request
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if (savedRequest != null) {
String attemptedUrl = savedRequest.getRedirectUrl();
// 1. Check if we have already warned them about this specific URL
String warnedUrl = (String) session.getAttribute("WARNED_URL");
// 2. Only show the message if this is a fresh interception
if (!attemptedUrl.equals(warnedUrl)) {
if (attemptedUrl.contains("/users/profile")) {
model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
} else {
model.addAttribute("messageWarning", "Please log in to access that page.");
}
// 3. Mark this URL as "warned" so we don't show the message again
// if they navigate away and come back to the login page manually.
session.setAttribute("WARNED_URL", attemptedUrl);
}
}
User user = new User();
// Grab the failed email attempt from Spring Security's session memory
String lastEmail = (String) session.getAttribute("LAST_EMAIL");
if (lastEmail != null) {
user.setEmail(lastEmail);
session.removeAttribute("LAST_EMAIL");
}
model.addAttribute("user", user);
return "auth/loginForm";
}The user sneaks into /users/profile. Spring Security intercepts them and saves the request.
They arrive at /login. attemptedUrl is /users/profile. warnedUrl is null.
Drop that tracking logic in, and let me know if it clears up that ghost warning! What is the next question on your list?
The controller shows the warning and sets WARNED_URL to /users/profile.
The user clicks "Home", then clicks "Login".
They arrive at /login. attemptedUrl is /users/profile. warnedUrl is /users/profile.
They match! The controller skips the warning, and the user just sees a perfectly clean login form.
You suggested this code for the schoolDetails.html file to display the logged in user's info on the school page.
```
<div class="col-lg-3">
<div th:if="${currentUser != null}" class="card shadow-sm border-primary">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">My Player Card</h5>
<a th:href="@{/users/profile}" class="btn btn-sm btn-light">Edit</a>
</div>
<div class="card-body">
<h5 class="card-title text-primary"
th:text="(${currentUser.nickname} ?: ${currentUser.firstName}) + ' ' + ${currentUser.lastName}">
Player Name
</h5>
<hr>
<div class="mb-3 text-sm">
<strong>Email:</strong> <br>
<span th:text="${currentUser.email}">student@school.edu</span>
<span th:if="${currentUser.publicEmail}" class="badge bg-success float-end">Public</span>
</div>
<div class="mb-3 text-sm" th:if="${currentUser.phone}">
<strong>Phone:</strong> <br>
<span th:text="${currentUser.phone}">(319) 555-0199</span>
<span th:if="${currentUser.publicPhone}" class="badge bg-success float-end">Public</span>
</div>
<div class="text-sm">
<strong>Language:</strong>
<span th:switch="${currentUser.preferredLanguage}">
<span th:case="'EN'">English</span>
<span th:case="'ES'">Spanish</span>
<span th:case="'KO'">Korean</span>
<span th:case="*">Not specified</span>
</span>
</div>
</div>
</div>
<div th:unless="${currentUser != null}" class="card shadow-sm">
<div class="card-body text-center">
<h5 class="card-title">Join the Action</h5>
<p class="card-text text-sm">Log in or register to join teams and view your player stats.</p>
<a th:href="@{/login}" class="btn btn-primary w-100 mb-2">Log In</a>
<a th:href="@{/register-student}" class="btn btn-outline-secondary w-100">Register</a>
</div>
</div>
</div>
```
If the first and last name are null in the database, the program displays "null null". How can the name and the `<hr>` tag be ignored if the user's name is not set?
To fix this, we can use one of Thymeleaf's best hidden features: the <th:block> tag.
A <th:block> is a "phantom" tag. It allows you to group multiple HTML elements together to apply Thymeleaf logic (like an if statement) to all of them at once, but when the page is actually rendered and sent to the browser, the <th:block> tag completely disappears so it doesn't mess up your CSS or HTML structure.
Here is how to wrap your name and <hr> tag so they only appear if the user actually has a name saved.
Replace your current <h5> and <hr> tags with this block:
<th:block th:if="${currentUser.firstName != null or currentUser.lastName != null or currentUser.nickname != null}">
<h5 class="card-title text-primary"
th:text="(${currentUser.nickname} ?: ${currentUser.firstName} ?: '') + ' ' + (${currentUser.lastName} ?: '')">
Player Name
</h5>
<hr>
</th:block>The th:if check: It checks if any of those three name fields exist in the database before deciding to render the block.
The "Elvis Operator" (?: ''): I added a fallback to an empty string '' inside the text concatenation. This ensures that if a user has a first name but their last name is null, it will just display "Alex" instead of "Alex null"!
Would you like to move on to building out those SchoolController CRUD endpoints so your admins can finally start managing these schools?
Swap that block in, and your "Player Card" will look perfectly clean for brand-new users who haven't filled out their profile yet.
When I visit the schoolDetails page I get an error saying "org.thymeleaf.exceptions.TemplateProcessingException: Could not parse as expression: "(${currentUser.nickname} ?: ${currentUser.firstName} ?: '') + ' ' + (${currentUser.lastName} ?: '')" (template: "schools/schoolDetails" - line 39, col 17)"
The error is happening because of exactly where the parentheses are placed. In Thymeleaf, if you want to do advanced logic like chaining Elvis operators (?:) and concatenating strings with +, the entire mathematical equation needs to happen inside a single ${...} block. Because my previous snippet put the parentheses outside the variable blocks, Thymeleaf's expression engine got completely confused.
However, we can fix this and make the code much easier to read by using one of Thymeleaf's best features: Literal Substitution.
By wrapping the entire string in pipe characters (|...|), we can drop the variables right into the text without needing any ugly + ' ' + concatenation!
Open your schools/schoolDetails.html file and update that <h5> tag to look exactly like this:
Swap that into your <th:block>, and your page will load perfectly, gracefully falling back from Nickname -> First Name -> Empty String, without crashing the template engine!
Assuming that clears the final layout bug, are you ready to jump into the SchoolController and build out the Create, Read, Update, and Delete methods?
<h5 class="card-title text-primary"
th:text="${(currentUser.nickname != null ? currentUser.nickname : currentUser.firstName ?: '') + ' ' + (currentUser.lastName ?: '')}">
Player Name
</h5>
When I use the "Edit Profile" form to update my first and last names, but not the nickname, the nickname changes from null to an empty string in the database. When I visit the page, only my last name appears. Can the logic be changed to display either first name only, first and last name, or nickname only?
This is a classic web development headache! You have discovered the fundamental difference between null (nothing exists) and "" (an empty string, which is technically something).
When you submit an HTML form, browsers don't send null for empty text boxes; they send an empty string "".
Because Thymeleaf's Elvis operator (?:) only looks for null, it sees your empty string nickname, says "Ah, there is data here!", and outputs the empty string. It then tacks on the last name, leaving you with a weird floating last name.
We can fix this by updating the Thymeleaf logic to handle empty strings, and I'll also show you a Spring Boot "pro-tip" to keep those empty strings out of your database completely.
To achieve your exact rules (Nickname only, OR First + Last, OR First only), we should ditch the confusing Elvis operators and use Thymeleaf's built-in #strings.isEmpty() utility. This checks for both null and "" at the same time.
By breaking it into two clear <span th:if="..."> tags inside your header, the logic becomes incredibly easy to read and maintain.
Update your schools/schoolDetails.html file to use this block:
<th:block th:if="${!#strings.isEmpty(currentUser.firstName) or !#strings.isEmpty(currentUser.nickname)}">
<h5 class="card-title text-primary">
<span th:if="${!#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.nickname}">Nickname</span>
<span th:if="${#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.firstName + (!#strings.isEmpty(currentUser.lastName) ? ' ' + currentUser.lastName : '')}">First Last</span>
</h5>
<hr>
</th:block>
While the Thymeleaf fix solves the display issue, you really don't want your database filling up with empty strings instead of proper null values. It makes writing SQL queries down the road much harder.
Spring Boot has a built-in tool called the StringTrimmerEditor that intercepts form submissions, trims off excess whitespace, and magically converts empty strings back into null before they ever hit your Entity or Database.
Open your ProfileController.java and add this single method anywhere inside the class:
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
// ... inside ProfileController ...
@InitBinder
public void initBinder(WebDataBinder binder) {
// The 'true' tells it to convert empty strings to null
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
Location.java entity and configure its one-to-many relationship with the School.java entity next?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.
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.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.