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.
http://localhost:8080/schools/xxx
Doesn't show 404 error.
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.
Add two new methods to the SchoolController to determine if the current 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 methods
private boolean checkEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Handle unauthenticated users safely
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
return false;
}
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());
return isSuperAdmin || (isSchoolAdmin && belongsToSchool);
}
private void verifyEditPermissions(School school) {
if (!checkEditPermissions(school)) {
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";
}Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.
@GetMapping("/{schoolId:\\d+}")
public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
// ADD THIS LINE
mav.addObject("canEdit", checkEditPermissions(school));
return mav;
}Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.
@GetMapping("/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
String fullDomain = slug + ".edu";
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findByDomain(fullDomain)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
// ADD THIS LINE
mav.addObject("canEdit", checkEditPermissions(school));
if (principal != null) {
// ... remainder of your existing method ...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|}"
th:if="${canEdit}"
class="btn btn-outline-primary">
Edit School
</a>
</div>The | characters in Thymeleaf represent Literal Substitution.
They act exactly like template literals in JavaScript (the backtick ` syntax) or f-strings in Python. They allow you to seamlessly mix plain text and variables without needing to use clunky string concatenation operators.
Without the pipes, you would have to use plus signs and single quotes to build the URL piece by piece, which is harder to read and prone to syntax errors:
@{'/schools/' + ${school.id} + '/edit'}
By wrapping the entire path in pipes, Thymeleaf knows to treat the whole block as a single string while still evaluating the ${school.id} variable inside it:
@{|/schools/${school.id}/edit|}
| charactersA student trying to access "/schools/1/edit" will get an error from the verifyEditPermissions method saying "You do not have permission to edit this school." School and Super Admins will not
Spring Boot provides a built-in mechanism to handle error pages dynamically based on your application's configuration properties.
This means you can display detailed error attributes (like the message and stack trace) during local development and hide them when deployed to production.
Spring Boot uses a feature called "Profiles" to manage different configurations for different environments.
By default, Spring Boot looks for a file named application.properties in your src/main/resources directory. This acts as your base configuration file. Any settings placed here apply to your application universally.
Create an application-dev.properties
# Display detailed error information on localhost
spring.web.error.include-message=always
spring.web.error.include-stacktrace=always
Create an application-prod.properties
# Hide detailed error information in production
spring.web.error.include-message=never
spring.web.error.include-stacktrace=never
When you create files named application-{profile}.properties, Spring Boot loads them alongside the base file when that specific profile is activated.
The profile-specific file takes precedence. If a property exists in both files, the value in the active profile file will override the value in the base file.
Spring Boot needs to be instructed on which profile is currently active.
You can set the active profile directly in your application.properties file.
# Set to dev locally, and prod on a cloud server
spring.profiles.active=${ACTIVE_PROFILE:dev}
Using ${ACTIVE_PROFILE} tells Spring Boot to look for an environment variable with that exact name at runtime.
This configuration tells Spring Boot to use the dev profile if the ACTIVE_PROFILE environment variable is missing. This prevents the application from failing to start during local development if you forget to set the variable on your machine.
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 ACTIVE_PROFILE=prod
If you need to add multiple environment variables in the future, you can pass them as a space-separated list to the same parameter:
--set-env-vars ACTIVE_PROFILE=prod DB_PASSWORD=your_password API_KEY=your_key
Spring Boot's BasicErrorController automatically passes error attributes to views located in the src/main/resources/templates/error/ directory.
Create a new file named 500.html in your templates/error/ directory:
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'error')}">
<body>
<div class="container text-center mt-5">
<h1 class="display-4 text-danger">500</h1>
<h2>Internal Server Error</h2>
<p class="lead">The server encountered an unexpected condition that prevented it from fulfilling the request.</p>
<a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>
<div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
<h4 class="alert-heading">Developer Details</h4>
<hr>
<p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
<p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
<p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
<div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
<pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
</div>
</div>
</div>
</body>
</html>When your verifyEditPermissions method throws an AccessDeniedException, Spring Security automatically intercepts it and translates it into an HTTP 403 (Forbidden) status code, rather than an HTTP 500 (Internal Server Error).
Create a new file named 403.html in your src/main/resources/templates/error/ directory:
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'error')}">
<body>
<div class="container text-center mt-5">
<h1 class="display-4 text-warning">403</h1>
<h2>Access Denied</h2>
<p class="lead">You do not have the required permissions to view or modify this resource.</p>
<a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>
<div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
<h4 class="alert-heading">Developer Details</h4>
<hr>
<p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
<p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
<p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
<div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
<pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
</div>
</div>
</div>
</body>
</html>handleGeneralError method from your GlobalExceptionHandler.Spring Boot's BasicErrorController is programmed to automatically look for a file named 500.html inside the templates/error/ directory whenever an unhandled server exception occurs.
By removing your manual interceptor, the default controller will take over, read your spring.web.error.include-stacktrace=always property, and automatically inject the stack trace into the Thymeleaf model.
Login as a student and try to access "/schools/1/edit". You will see the new error page.
To simulate a true 500 Internal Server Error, you can inject a hard crash into one of your existing routes.
Open your SchoolController and add a throw new RuntimeException statement in a new route:
@GetMapping("/bad")
public void badSchoolRequest() {
if (true) {
throw new RuntimeException("This is a simulated database failure to test the 500 page stack trace.");
}
}Visit "/schools/bad" to see this error.
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>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}.
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>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.
It would be nice if it displayed a warning message saying something like "You must be logged in to edit your profile"
Spring Security intercepts the guest user and secretly saves the 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 the Auth Controller to figure out where they came from and display a custom warning message.
Find the controller method that serves your custom login page (@GetMapping("/login")).
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 initLoginForm(Model model, HttpSession session) {
// 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.");
}
}
User user = new User();
String lastEmail = (String)session.getAttribute("LAST_EMAIL");
if(lastEmail != null) {
user.setEmail(lastEmail);
session.removeAttribute("LAST_EMAIL");
}
model.addAttribute("user", user);
return "auth/loginForm";
}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 display your custom warning.
If your layout.html file doesn't contain a messageWarning alert box element, you can add one to your loginForm.html template and display that messageWarning attribute.
Add this somewhere near the top of your login form container:
<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>
The "You must be logged in to edit your profile." warning message displays when you 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.
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!
We can 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 guest user visits /users/profile. Spring Security intercepts them and saves the request.
They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is null.
The controller shows the warning and sets WARNED_URL to /users/profile.
The user clicks "Home", then clicks "Login".
They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is /users/profile.
They match! The controller skips the warning, and the user just sees a clean login form.
To finish building the relationship of Schools and Locations, we will mirror the architectural pattern used for Owners and Pets. The School acts as the parent (like Owner), and the Location acts as the child (like Pet).
With the data layer configured (School.java, Location.java), the next step is to create the LocationRepository interface.
Because we will need to fetch locations specific to the school being viewed, add a custom finder method that filters by the school_id.
This uses a SQL Stored procedure, rather than a SELECT statement.
Using nativeQuery = true is the most reliable way to map a stored procedure's result set directly into a List of JPA entities.
package org.springframework.samples.petclinic.school;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface LocationRepository extends CrudRepository<Location, Integer> {
// Call the MySQL stored procedure natively
@Query(value = "CALL get_locations_by_school(:schoolId)", nativeQuery = true)
List<Location> findBySchoolId(@Param("schoolId") int schoolId);
}Spring Data JPA fully supports calling database procedures and mapping the result sets back to your Java entities.
However, there is a critical technical caveat: Native queries and stored procedures completely bypass Hibernate's @SQLRestriction annotation (See line 18 of Location.java and line 24 of School.java).
If you use a stored procedure, you must manually enforce the soft-delete logic inside the SQL, otherwise, your application will start fetching deleted locations.
Add this procedure definition to your MySQL database. Notice that we must explicitly include the deleted_at IS NULL check.
DELIMITER //
CREATE PROCEDURE get_locations_by_school(IN p_school_id INT)
BEGIN
SELECT * FROM locations
WHERE school_id = p_school_id
AND deleted_at IS NULL;
END //
DELIMITER ;It is generally considered a best practice to avoid stored procedures for simple SELECT statements in a Spring Boot application.
When using this code instead...
@Query("SELECT l FROM Location l WHERE l.school.id = :schoolId")
List<Location> findBySchoolId(@Param("schoolId") int schoolId);
...Spring's built-in method naming conventions
keeps your business logic inside the Java codebase
maintains database agnosticism (allowing you to switch to PostgreSQL or H2 for testing without rewriting SQL)
ensures annotations like @SQLRestriction function automatically.
Stored procedures are typically reserved for complex, multi-table data processing operations where the database engine can execute the logic faster than the Java application.
The next step is to create the LocationController to handle the routing and the MANAGE_FACILITIES permission checks.
By using Spring's @ModelAttribute annotation at the method level, we can intercept every request to this controller, fetch the parent School entity, and verify the user's permissions before any route-specific logic is executed. This prevents you from having to copy and paste the security check into every method.
package org.springframework.samples.petclinic.school;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/schools/{schoolId}/locations")
public class LocationController {
private final SchoolRepository schoolRepository;
private final LocationRepository locationRepository;
public LocationController(SchoolRepository schoolRepository, LocationRepository locationRepository) {
this.schoolRepository = schoolRepository;
this.locationRepository = locationRepository;
}
// This executes before every route in this controller to populate the school and check permissions
@ModelAttribute("school")
public School findSchoolAndVerifyPermissions(@PathVariable("schoolId") int schoolId) {
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
throw new AccessDeniedException("Authentication required.");
}
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 manage facilities for this school.");
}
return school;
}
@GetMapping("/new")
public String initCreationForm(School school, ModelMap model) {
Location location = new Location();
school.addLocation(location);
model.put("location", location);
return "locations/createOrUpdateLocationForm";
}
@PostMapping("/new")
public String processCreationForm(School school, @Valid Location location, BindingResult result, ModelMap model) {
if (result.hasErrors()) {
model.put("location", location);
return "locations/createOrUpdateLocationForm";
}
school.addLocation(location);
locationRepository.save(location);
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}
@GetMapping("/{locationId}/edit")
public String initUpdateForm(@PathVariable("locationId") int locationId, ModelMap model) {
Location location = locationRepository.findById(locationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));
model.put("location", location);
return "locations/createOrUpdateLocationForm";
}
@PostMapping("/{locationId}/edit")
public String processUpdateForm(@Valid Location location, BindingResult result, School school,
@PathVariable("locationId") int locationId, ModelMap model) {
if (result.hasErrors()) {
location.setId(locationId);
model.put("location", location);
return "locations/createOrUpdateLocationForm";
}
location.setId(locationId);
school.addLocation(location);
locationRepository.save(location);
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}
@PostMapping("/{locationId}/delete")
public String processDeleteForm(@PathVariable("locationId") int locationId, School school) {
Location location = locationRepository.findById(locationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));
// Because Location.java has the @SQLDelete annotation, this will execute an UPDATE query
// setting the deleted_at column to NOW(), rather than executing a hard DELETE.
locationRepository.delete(location);
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}
}Create a new directory named locations inside your src/main/resources/templates/ folder. Inside that directory, create the createOrUpdateLocationForm.html file.
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<body>
<h2 th:text="${location.id == null} ? 'Add Location' : 'Update Location'">Add Location</h2>
<form th:object="${location}" class="form-horizontal" id="add-location-form" method="post"
th:action="${location.id == null} ? @{|/schools/${school.id}/locations/new|} : @{|/schools/${school.id}/locations/${location.id}/edit|}">
<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>
<input th:replace="~{fragments/inputField :: input ('Name', 'name', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('Address', 'address', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('Description', 'description', 'text')}" />
<div class="row">
<div class="col-md-6">
<input th:replace="~{fragments/inputField :: input ('Latitude', 'latitude', 'number')}" step="any" />
</div>
<div class="col-md-6">
<input th:replace="~{fragments/inputField :: input ('Longitude', 'longitude', 'number')}" step="any" />
</div>
</div>
<div class="mb-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" th:field="*{status}">
<option value="ACTIVE">Active</option>
<option value="DRAFT">Draft</option>
<option value="COMING_SOON">Coming Soon</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<div class="form-group mt-4">
<button class="btn btn-primary" type="submit"
th:text="${location.id == null} ? 'Add Location' : 'Update Location'">Add Location</button>
<a class="btn btn-outline-secondary ms-2" th:href="@{|/schools/${school.id}|}">Cancel</a>
</div>
</form>
</body>
</html>Once this is saved, you will need to add the "Add Location" button and loop through the existing locations on your schoolDetails.html page to display the current data. Let me know when you are ready to configure the details view.
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.