Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Part 1
Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.
Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,
Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.
Lesson 6 - User Registration, flash messages, Session Cookies
Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.
Lesson 8 - Midterm exam. Prevent duplicate insert records.
Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.
Lesson 10 - Recipe API. User permissions. Custom not found page.
Lessons 11-12 - Update and delete existing Location objects.
Lessons 13-14 - Azure Email. Web Sockets.
Final Exam - CRUD Leagues (fill in the blanks).
Did not get to - Search, sort, filter records. Date and currency formatting. Failed login attempts. SMS messaging. Shopping Cart. Payment processing. Team Registration. Event Creation.
http://localhost:8080/schools/xxx Doesn't show 404 error.
SL4FJ Logging (See EmailService)
Delete reset_token on login
Add a failed to connect toast if cannot connect to db
Sort and filter the list of schools
Add school logo, color
If the user enters an incorrect email or password N times, can their account be disabled?
Get the location's lat/lon from LocationIQ
Get weather forecast at the location's lat/lon from the OpenWeatherAPI
School Admin upload student data
Date an Time dont populate when editing
the edit fields aren't red
deleting a route sends to null routeCode URL
Wireless LAN adapter ipv4 address
http://192.168.1.21:8080/WebCalculators_MW_war_exploded/group-chat
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
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)
);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
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.
Start with a working copy of the Athleagues demo project or clone a new copy before beginning.
UNIQUE constraint to the table. For intramural leagues, a combination of the school_id and the league name is a reliable natural key (for example, Kirkwood would have only one league called "Fall 2026 5v5 Basketball").CREATE TABLE IF NOT EXISTS leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
location_id INT,
user_id INT,
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',
copied_from_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME,
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,
CONSTRAINT fk_leagues_copied_from FOREIGN KEY (copied_from_id) REFERENCES leagues(id) ON DELETE SET NULL,
INDEX idx_leagues_school_status (school_id, status_id),
-- TODO: Add a line to prevent duplicate seed data
);CREATE TABLE IF NOT EXISTS leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
location_id INT,
user_id INT,
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',
copied_from_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME,
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,
CONSTRAINT fk_leagues_copied_from FOREIGN KEY (copied_from_id) REFERENCES leagues(id) ON DELETE SET NULL,
INDEX idx_leagues_school_status (school_id, status_id),
-- Add this line to prevent duplicate seed data
UNIQUE INDEX idx_leagues_school_name (school_id, name)
);UNIQUE constraint to the table. For teams, a combination of the league_id and the team name is a reliable natural key.CREATE TABLE IF NOT EXISTS 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,
deleted_at DATETIME,
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),
-- Add this line to prevent duplicate seed data
UNIQUE INDEX idx_leagues_team_name (league_id, name)
);
CREATE TABLE IF NOT EXISTS 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,
deleted_at DATETIME,
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)
);UNIQUE constraint to the table. For teams, a combination of the league_id and the event name is a reliable natural key.CREATE TABLE IF NOT EXISTS 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,
deleted_at DATETIME,
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),
-- Add this line to prevent duplicate seed data
UNIQUE INDEX idx_leagues_event_name (league_id, name)
);
CREATE TABLE IF NOT EXISTS 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,
deleted_at DATETIME,
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)
);-- Create a League
INSERT IGNORE INTO leagues
(school_id, location_id, user_id, name, description, registration_start, registration_end, league_start, league_end, is_public, type, capacity, capacity_type, fee, status_id)
VALUES
(1, 3, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Fall 2026 5v5 Basketball', 'Competitive 5-on-5 men''s basketball league.', '2026-08-01 08:00:00', '2026-08-31 23:59:59', '2026-09-07 18:00:00', '2026-11-15 22:00:00', 1, 'male', 12, 'team', 50.00, 'active');
Here are some INSERT IGNORE INTO statements to seed your new tables in your resources/db/mysql/data.sql file.
To ensure the foreign keys map correctly regardless of the auto-increment state in your database, these statements use subqueries based on the names and emails provided in previous insert blocks.
You must have previous roles, users, and locations set up.
-- 1. Insert a second student to act as an opposing team captain
INSERT IGNORE INTO users (first_name, last_name, email, password_hash) VALUES
('Sam', 'Student', 'sam.student@student.kirkwood.edu', 'hashed_password_for_sam');
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
((SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), (SELECT id FROM roles WHERE name = 'STUDENT'));
-- 2. Create the League
-- Manager: Brett Admin | Location: Rec Center (ID 3)
INSERT IGNORE INTO leagues
(school_id, location_id, user_id, name, description, registration_start, registration_end, league_start, league_end, is_public, type, capacity, capacity_type, fee, status_id)
VALUES
(1, 3, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Fall 2026 5v5 Basketball', 'Competitive 5-on-5 men''s basketball league.', '2026-08-01 08:00:00', '2026-08-31 23:59:59', '2026-09-07 18:00:00', '2026-11-15 22:00:00', 1, 'male', 12, 'team', 50.00, 'active');
-- 3. Create the Teams
INSERT IGNORE INTO teams
(league_id, captain_user_id, name, status_id)
VALUES
((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), (SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), 'The Eagles', 'active'),
((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), (SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), 'Alley-Oops', 'active');
-- 4. Assign Users to Teams (Roster)
INSERT IGNORE INTO team_users
(team_id, user_id, role)
VALUES
((SELECT id FROM teams WHERE name = 'The Eagles' LIMIT 1), (SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), 'captain'),
((SELECT id FROM teams WHERE name = 'Alley-Oops' LIMIT 1), (SELECT id FROM users WHERE email = 'sam.student@student.kirkwood.edu'), 'captain');
-- 5. Create an Event (e.g., Week 1 Game Night)
-- Location: Basketball Court 1 (ID 7)
INSERT IGNORE INTO events
(league_id, location_id, user_id, name, description, event_start, event_end, status_id)
VALUES
((SELECT id FROM leagues WHERE name = 'Fall 2026 5v5 Basketball' LIMIT 1), 7, (SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), 'Week 1 Games', 'Opening night matchups on Court 1', '2026-09-07 18:00:00', '2026-09-07 20:00:00', 'active');
-- 6. Schedule a Match within that Event
INSERT IGNORE INTO matches
(event_id, home_team_id, away_team_id, status)
VALUES
((SELECT id FROM events WHERE name = 'Week 1 Games' LIMIT 1), (SELECT id FROM teams WHERE name = 'The Eagles' LIMIT 1), (SELECT id FROM teams WHERE name = 'Alley-Oops' LIMIT 1), 'scheduled');This includes teams and more
NotBlank.league.name=Please provide a unique name for this season's league.
NotNull.league.defaultLocation=Please select a default location for this league.
NotNull.league.locationId=Please select a default location for this league.
NotNull.league.type=Please select the gender format.
NotNull.league.capacity=Please enter a team capacity.
Min.league.capacity=The capacity limit must be at least 1.
NotNull.league.capacityType=Please select the registration type.
NotNull.league.status=League status cannot be blank.Update src/main/resources/messages.properties to customize the league input error messages.
Spring Boot automatically generates highly specific error codes based on a formula: AnnotationName.objectName.fieldName
YOUR TASK: Create a NotNull message for league fees saying, "Please enter a registration fee (enter 0 for free leagues)." Create a Min message for league fees saying, "The registration fee cannot be negative."
NotBlank.league.name=Please provide a unique name for this season's league.
NotNull.league.defaultLocation=Please select a default location for this league.
NotNull.league.locationId=Please select a default location for this league.
NotNull.league.type=Please select the gender format.
NotNull.league.capacity=Please enter a team capacity.
Min.league.capacity=The capacity limit must be at least 1.
NotNull.league.capacityType=Please select the registration type.
NotNull.league.fee=Please enter a registration fee (enter 0 for free leagues).
Min.league.fee=The registration fee cannot be negative.
NotNull.league.status=League status cannot be blank.SOLUTION
League.java inside a new league package with the following code.isPublic attribute, Lombok will automatically create a getter named isPublic(). However, Thymeleaf will strictly look for a method named getIsPublic() or isIsPublic().
public because it's a reserved word.getIsPublic and setIsPublic methods to the League class.package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.user.User;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "leagues")
@Getter
@Setter
@SQLDelete(sql = "UPDATE leagues SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class League extends NamedEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", nullable = false)
private School school;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "location_id")
private Location defaultLocation;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User manager;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "copied_from_id")
private League copiedFrom;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "registration_start")
private LocalDateTime registrationStart;
@Column(name = "registration_end")
private LocalDateTime registrationEnd;
@Column(name = "league_start")
private LocalDateTime leagueStart;
@Column(name = "league_end")
private LocalDateTime leagueEnd;
@Column(name = "is_public")
private boolean isPublic = true;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private LeagueType type;
@Column(name = "capacity")
private Integer capacity;
@Enumerated(EnumType.STRING)
@Column(name = "capacity_type", nullable = false)
private CapacityType capacityType;
@Column(name = "fee", precision = 6, scale = 2)
private BigDecimal fee;
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
private LeagueStatus status = LeagueStatus.DRAFT;
// Enums
public enum LeagueType { MALE, FEMALE, COED }
public enum CapacityType { TEAM, INDIVIDUAL }
public enum LeagueStatus { DRAFT, ACTIVE, INACTIVE, POSTPONED, CANCELLED, PAST }
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}
package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.user.User;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "leagues")
@Getter
@Setter
@SQLDelete(sql = "UPDATE leagues SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class League extends NamedEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", nullable = false)
private School school;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "location_id")
private Location defaultLocation;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User manager;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "copied_from_id")
private League copiedFrom;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "registration_start")
private LocalDateTime registrationStart;
@Column(name = "registration_end")
private LocalDateTime registrationEnd;
@Column(name = "league_start")
private LocalDateTime leagueStart;
@Column(name = "league_end")
private LocalDateTime leagueEnd;
@Column(name = "is_public")
private boolean isPublic = true;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private LeagueType type;
@Column(name = "capacity")
private Integer capacity;
@Enumerated(EnumType.STRING)
@Column(name = "capacity_type", nullable = false)
private CapacityType capacityType;
@Column(name = "fee", precision = 6, scale = 2)
private BigDecimal fee;
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
private LeagueStatus status = LeagueStatus.DRAFT;
@OneToMany(mappedBy = "league", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Team> teams = new ArrayList<>();
// Enums
public enum LeagueType { MALE, FEMALE, COED }
public enum CapacityType { TEAM, INDIVIDUAL }
public enum LeagueStatus { DRAFT, ACTIVE, INACTIVE, POSTPONED, CANCELLED, PAST }
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public boolean getIsPublic() {
return this.isPublic;
}
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
}
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
// ... inside your League class ...
@NotNull
@Min(1)
@Column(name = "capacity")
private Integer capacity;YOUR TASK: Update League.java to add the necessary input validation annotations for the type, capacityType, fee, and status attributes.
This work will relate to what you added to messages.properties
An example for capacity is given.
These relate to the following messages:
NotNull.league.capacity=Please enter a team capacity.
Min.league.capacity=The capacity limit must be at least 1.
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
// ... inside your League class ...
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private LeagueType type;
@NotNull
@Min(1)
@Column(name = "capacity")
private Integer capacity;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "capacity_type", nullable = false)
private CapacityType capacityType;
@NotNull
@Min(0)
@Column(name = "fee", precision = 6, scale = 2)
private BigDecimal fee;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
private LeagueStatus status = LeagueStatus.DRAFT;
// ... leave dates and description without @NotNull so Drafts can be saved ...SOLUTION
Note to future self: Do not add @NotNull to your four Date fields. If you do, your cloneLeagueForNewSeason method will crash when it attempts to save the newly cloned draft with empty dates!
leave dates and description without @NotNull so Drafts can be saved
Team.java inside the same package.package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.user.User;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "teams")
@Getter
@Setter
@SQLDelete(sql = "UPDATE teams SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Team extends NamedEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "league_id", nullable = false)
private League league;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "captain_user_id", nullable = false)
private User captain;
@Column(name = "logo_url")
private String logoUrl;
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
private TeamStatus status = TeamStatus.ACTIVE;
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private List<TeamUser> roster = new ArrayList<>();
public enum TeamStatus { ACTIVE, INACTIVE, SUSPENDED }
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}
TeamUser.java inside the same package.package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.BaseEntity;
import org.springframework.samples.petclinic.user.User;
import java.time.LocalDateTime;
@Entity
@Table(name = "team_users")
@Getter
@Setter
@SQLDelete(sql = "UPDATE team_users SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class TeamUser extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false)
private Team team;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "role")
private RosterRole role = RosterRole.MEMBER;
public enum RosterRole { MEMBER, CAPTAIN }
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}
Event.java inside the same package.package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;
import org.springframework.samples.petclinic.school.Location;
import org.springframework.samples.petclinic.user.User;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "events")
@Getter
@Setter
@SQLDelete(sql = "UPDATE events SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Event extends NamedEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "league_id", nullable = false)
private League league;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "location_id")
private Location location;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User organizer;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "event_start")
private LocalDateTime eventStart;
@Column(name = "event_end")
private LocalDateTime eventEnd;
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
private EventStatus status = EventStatus.DRAFT;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Match> matches = new ArrayList<>();
public enum EventStatus { DRAFT, ACTIVE, ONGOING, POSTPONED, CANCELLED, FINAL }
// Use Lombok @Getter/@Setter or generate standard getters and setters here
}
Match.java inside the same package.package org.springframework.samples.petclinic.league;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.BaseEntity;
import java.time.LocalDateTime;
@Entity
@Table(name = "matches")
@Getter
@Setter
@SQLDelete(sql = "UPDATE matches SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Match extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private Event event;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "home_team_id")
private Team homeTeam;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "away_team_id")
private Team awayTeam;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "winner_team_id")
private Team winnerTeam;
@Column(name = "home_score")
private Integer homeScore = 0;
@Column(name = "away_score")
private Integer awayScore = 0;
@Enumerated(EnumType.STRING)
@Column(name = "status")
private MatchStatus status = MatchStatus.SCHEDULED;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public enum MatchStatus { SCHEDULED, IN_PROGRESS, FINAL, FORFEIT }
// Use Lombok @Getter/@Setter or generate standard getters and setters here
}
league package, create a Spring Data JPA repository for the League entity.package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface LeagueRepository extends ??? {
List<League> findBySchoolIdOrderByLeagueStartDesc(Integer schoolId);
???("SELECT l FROM League l WHERE l.school.id = :schoolId " +
"AND l.status <> :draftStatus " +
"AND (l.leagueEnd IS NULL OR l.leagueEnd > :now) " +
"ORDER BY l.leagueStart ASC")
List<League> findActiveLeagues(@Param("schoolId") Integer schoolId,
@Param("draftStatus") League.LeagueStatus draftStatus,
@Param("now") LocalDateTime now);
}package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface LeagueRepository extends JpaRepository<League, Integer> {
// Matches your idx_leagues_school_status index
List<League> findBySchoolIdAndStatus(Integer schoolId, League.LeagueStatus status);
List<League> findBySchoolId(Integer schoolId);
List<League> findBySchoolIdOrderByLeagueStartDesc(Integer schoolId);
@Query("SELECT l FROM League l WHERE l.school.id = :schoolId " +
"AND l.status <> :draftStatus " +
"AND (l.leagueEnd IS NULL OR l.leagueEnd > :now) " +
"ORDER BY l.leagueStart ASC")
List<League> findActiveLeagues(@Param("schoolId") Integer schoolId,
@Param("draftStatus") League.LeagueStatus draftStatus,
@Param("now") LocalDateTime now);
}package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeamRepository extends JpaRepository<Team, Integer> {
// Matches your idx_teams_league index
List<Team> findByLeagueId(Integer leagueId);
}
package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface TeamUserRepository extends JpaRepository<TeamUser, Integer> {
List<TeamUser> findByTeamId(Integer teamId);
// Enforces the unique user-per-team constraint
Optional<TeamUser> findByTeamIdAndUserId(Integer teamId, Integer userId);
}package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface EventRepository extends JpaRepository<Event, Integer> {
List<Event> findByLeagueId(Integer leagueId);
}
package org.springframework.samples.petclinic.league;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface MatchRepository extends JpaRepository<Match, Integer> {
List<Match> findByEventId(Integer eventId);
List<Match> findByHomeTeamIdOrAwayTeamId(Integer homeTeamId, Integer awayTeamId);
}
package org.springframework.samples.petclinic.league;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
@Service
public class LeagueService {
private final LeagueRepository leagueRepository;
public LeagueService(LeagueRepository leagueRepository) {
this.leagueRepository = leagueRepository;
}
@Transactional
public League cloneLeagueForNewSeason(Integer sourceLeagueId) {
League sourceLeague = leagueRepository.findById(sourceLeagueId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Source league not found"));
League newLeague = new League();
// 1. Link the lineage
newLeague.setCopiedFrom(sourceLeague);
newLeague.setSchool(sourceLeague.getSchool());
// 2. Copy the configuration settings
newLeague.setName(sourceLeague.getName() + " (Copy)");
newLeague.setDescription(sourceLeague.getDescription());
newLeague.setType(sourceLeague.getType());
newLeague.setCapacity(sourceLeague.getCapacity());
newLeague.setCapacityType(sourceLeague.getCapacityType());
newLeague.setFee(sourceLeague.getFee());
newLeague.setIsPublic(sourceLeague.getIsPublic());
// Default location and manager can be copied, as they often remain the same
newLeague.setDefaultLocation(sourceLeague.getDefaultLocation());
newLeague.setManager(sourceLeague.getManager());
// 3. Reset state-specific fields for the new season
newLeague.setStatus(League.LeagueStatus.DRAFT);
// Dates are left null to force the admin to set the new season's schedule
newLeague.setRegistrationStart(null);
newLeague.setRegistrationEnd(null);
newLeague.setLeagueStart(null);
newLeague.setLeagueEnd(null);
// 4. Save and return the new entity
// Note: Teams and Events are NOT copied, as the new league needs a fresh roster and schedule.
return leagueRepository.save(newLeague);
}
}
Here is the service logic required to safely clone a league.
SCHOOL_ADMIN copies a league from a previous year, they will typically want the configuration (rules, fees, capacities) but not the old rosters, matches, or historical dates.Create LeagueService.java. This service handles the transaction of duplicating the league entity while intentionally dropping the historical baggage.
// -- Imports and annotations omitted --
public class School extends NamedEntity {
// -- Code omitted --
public String getSlug() {
if (this.domain == null) {
return "";
}
return this.domain.replace(".edu", "");
}
}
Add a getSlug() method to your School.java file. This allows you to access the slug directly in Thymeleaf templates using ${school.slug} rather than ${school.domain.substring(0, school.domain.length() - 4)}, which we used in schoolList.html.
We will also use it to redirect "/schools/1" to "/schools/kirkwood".
import org.springframework.samples.petclinic.league.League;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;
// ... inside your controller ...
@GetMapping("/{schoolId:\\d+}")
public String redirectToSlug(@PathVariable("schoolId") int schoolId) {
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
return "redirect:/schools/" + school.getSlug();
}
@GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
// ... existing code omitted ...
// Fetch the appropriate leagues
List<League> leagues;
if (checkEditPermissions(school)) {
leagues = leagueRepository.findBySchoolIdOrderByLeagueStartDesc(school.getId());
} else {
leagues = leagueRepository.findActiveLeagues(school.getId(), League.LeagueStatus.DRAFT, LocalDateTime.now());
}
mav.addObject("leagues", leagues);
return mav;
}Open your SchoolController.java.
YOUR TASK: Inject the LeagueRepository into the constructor.
Then, using the code provided, update your @GetMapping method for the school details page to check the user's security context and execute the appropriate query.
Delete the showSchool/showSchoolById method that matches "/schools/1" and replace it with a method that redirects to "/schools/kirkwood".
import org.springframework.samples.petclinic.league.League;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;
@Controller
@RequestMapping("/schools")
public class SchoolController {
private final SchoolRepository schoolRepository;
private final UserRepository userRepository;
private final LeagueRepository leagueRepository;
public SchoolController(SchoolRepository schoolRepository, UserRepository userRepository, LeagueRepository leagueRepository) {
this.schoolRepository = schoolRepository;
this.userRepository = userRepository;
this.leagueRepository = leagueRepository;
}
// ... inside your controller ...
@GetMapping("/{schoolId:\\d+}")
public String redirectToSlug(@PathVariable("schoolId") int schoolId) {
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
return "redirect:/schools/" + school.getSlug();
}
@GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
// ... existing code omitted ...
// Fetch the appropriate leagues
List<League> leagues;
if (checkEditPermissions(school)) {
leagues = leagueRepository.findBySchoolIdOrderByLeagueStartDesc(school.getId());
} else {
leagues = leagueRepository.findActiveLeagues(school.getId(), League.LeagueStatus.DRAFT, LocalDateTime.now());
}
mav.addObject("leagues", leagues);
return mav;
}SOLUTION
<div class="d-flex justify-content-between align-items-center">
<h2>Intramural Leagues</h2>
<a th:href="@{|/schools/${school.id}/leagues/new|}"
th:if="${canEdit}"
class="btn btn-primary mt-0">Create League</a>
</div>
<table th:if="${!leagues.isEmpty()}" class="table table-striped table-hover mt-3">
<thead>
<tr>
<th>League Name</th>
<th>Type</th>
<th>Registration Deadline</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="league : ${leagues}">
<td th:text="${league.name}">League Name</td>
<td th:text="${#strings.capitalize(league.type.name().toLowerCase())}">Coed</td>
<td th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Oct 31</td>
<td>
<span th:text="${league.status}"
class="badge"
th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}">
ACTIVE
</span>
</td>
<td>
<a th:href="@{|/schools/${school.id}/leagues/${league.id}|}" class="btn btn-sm btn-outline-primary">View Info</a>
</td>
</tr>
</tbody>
</table>Open schoolDetails.html.
Replace the following crossed out lines with the code below to display leagues. This includes Thymeleaf logic to format the dates and apply conditional CSS badges based on the league's status.
<div class="card shadow-sm h-100">
<div class="card-body text-center">
<!-- icon, heading, and paragraph omitted -->
</div>
</div>
YOUR TASK: Add another block that displays "There are no active leagues at this time." if the list of leagues is empty.
<div class="d-flex justify-content-between align-items-center">
<h2>Intramural Leagues</h2>
<a th:href="@{|/schools/${school.id}/leagues/new|}"
th:if="${canEdit}"
class="btn btn-primary mt-0">Create League</a>
</div>
<table th:if="${!leagues.isEmpty()}" class="table table-striped table-hover mt-3">
<thead>
<tr>
<th>League Name</th>
<th>Type</th>
<th>Registration Deadline</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="league : ${leagues}">
<td th:text="${league.name}">League Name</td>
<td th:text="${#strings.capitalize(league.type.name().toLowerCase())}">Coed</td>
<td th:text="${league.registrationEnd != null ? #temporals.format(league.registrationEnd, 'MMM dd, yyyy') : 'TBD'}">Oct 31</td>
<td>
<span th:text="${league.status}"
class="badge"
th:classappend="${league.status.name() == 'DRAFT' ? 'bg-warning text-dark' : 'bg-success'}">
ACTIVE
</span>
</td>
<td>
<a th:href="@{|/schools/${school.id}/leagues/${league.id}|}" class="btn btn-sm btn-outline-primary">View Info</a>
</td>
</tr>
</tbody>
</table>
<p th:if="${leagues.isEmpty()}" class="text-muted mt-3">There are no active leagues at this time.</p>SOLUTION
Raise your hand and show Marc these two URLs:
http://localhost:8080/schools/1
This should redirect to "/schools/kirkwood"
http://localhost:8080/schools/kirkwood
This should display one "Fall 2026 5v5 Basketball" league
http://localhost:8080/schools/2
This should redirect to "/schools/uiowa"
http://localhost:8080/schools/uiowa
This should display a message saying "There are no active leagues at this time."
Push to GitHub and deploy to Azure if you cannot continue to the next part.
If you completed the Pet Clinic Rest API as part of the final exam exemption, please show that to Marc too.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.