Java 3 - 2026

Weeks 13-14

Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.

Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,

Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.

Lesson 6 - User Registration, flash messages, Session Cookies

Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.

Lesson 8 - Midterm exam. Prevent duplicate insert records.

Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.

Lesson 10 - Recipe API. User permissions. Custom not found page.
Lessons 11-12 - Update and delete existing Location objects.
Lessons 13-14 - Azure
Email. Search, sort, filter records. Date and currency formatting. Web Sockets.

Final Exam - CRUD Events (fill in the blanks).

Not getting to - Failed login attempts.  SMS messaging. Shopping Cart. Payment processing. Event Registration.

Course Plan

  • 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?

  • Email confirmation
  • Update messages.properties files to include needed translations for the registration form headings, labels, and flash messages
  • Stored procedures
  • 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

Next

  • XSS Attacks, other OWASP

  • CSRF? Security
  • First name field

  • <img src="http://localhost:9999/city.png" onclick="location='http://packt.com'">

  • <button class="btn btn-danger" onclick="location='http://packt.com'">Click</button>

  • An internal server error occurred.

    could not execute statement [Data truncation: Data too long for column 'first_name' at row 1]

  • SQL injection

  • Juice box app

  • Using a python app to make post requests to my register route - use captcha to prevent that.

  • File/image upload

Next

  • 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

Bootstrap Styling

  • The hierarchy of Kirkwood locations is as follows:

    - Main Campus (Main Parent)

    -- Johnson Hall (Child of Main Campus)

    -- Michael J Gould Rec Center (Child of Main Campus)

    --- Basketball Court 1 (Child of Michael J Gould Rec Center)

    --- Basketball Court 2 (Child of Michael J Gould Rec Center)

  • It is possible to format the table and the select menu to display in an ordered hierarchy.

  • To achieve this, we can write a recursive algorithm that flattens the parent-child relationships into a sorted list, while tracking the "depth" of each location.

  • By adding this logic directly to the School entity, both the table and your dropdown menu can access the ordered data without needing to change your repository or database queries.

Order of Parent Locations

  • We need a place to store the depth calculation (for the table padding) and the hyphenated string (for the dropdown). We use the @Transient annotation to tell Hibernate to hold this data in memory for the UI, but never attempt to save it to the database.

  • Open Location.java and add these two variables. (Since we are using Lombok's @Getter and @Setter annotations, you do not need to manually write the get/set methods).

Transient Fields in Location.java

    import jakarta.persistence.Transient;

    // ... your existing @Column code ...

    // --- NEW UI HELPER FIELDS ---
    @Transient
    private int depth;

    @Transient
    private String hierarchyName;

    public enum LocationStatus {
       DRAFT, ACTIVE, CLOSED, COMING_SOON;
    }
  • In your School.java file, we will add a new @Transient method that reads the flat list of locations, finds the "roots" (locations with no parent), and recursively digs down to build a sorted, flattened tree.

  • Add this code below your existing addLocation method:

Sorting Logic in School.java

    import jakarta.persistence.Transient;
    import java.util.Comparator;

    // ... your existing code ...

    public void addLocation(Location location) {
       location.setSchool(this);
       getLocations().add(location);
    }

    // --- NEW RECURSIVE SORTING LOGIC ---

    @Transient
    public List<Location> getSortedHierarchy() {
        List<Location> sortedHierarchy = new ArrayList<>();
        List<Location> allLocations = getLocations();

        // 1. Find all root locations (those without a parent) and sort them alphabetically
        List<Location> roots = allLocations.stream()
            .filter(loc -> loc.getParentLocation() == null)
            .sorted(Comparator.comparing(Location::getName))
            .toList();

        // 2. Start the recursive tree build for each root
        for (Location root : roots) {
            buildTree(root, allLocations, 0, sortedHierarchy);
        }

        return sortedHierarchy;
    }

    private void buildTree(Location current, List<Location> allLocations, int depth, List<Location> sortedHierarchy) {
        // Set the transient UI fields
        current.setDepth(depth);
        String prefix = "-".repeat(depth);
        current.setHierarchyName((depth > 0 ? prefix + " " : "") + current.getName());
        
        sortedHierarchy.add(current);

        // Find all children of the current location and sort them alphabetically
        List<Location> children = allLocations.stream()
            .filter(loc -> loc.getParentLocation() != null && loc.getParentLocation().getId().equals(current.getId()))
            .sorted(Comparator.comparing(Location::getName))
            .toList();

        // Recurse into each child
        for (Location child : children) {
            buildTree(child, allLocations, depth + 1, sortedHierarchy);
        }
    }
  • Open LocationController.java. Update your populateParentLocations method to call the new getSortedHierarchy() method instead of the raw getLocations() list.

Update the LocationController

    @ModelAttribute("parentLocations")
    public List<Location> populateParentLocations(School school, @PathVariable(required = false) Integer locationId) {
        // Use the newly sorted hierarchy
        return school.getSortedHierarchy().stream()
                .filter(loc -> loc.getId() != null)
                .filter(loc -> locationId == null || !loc.getId().equals(locationId))
                .toList();
    }
  • Standard HTML <select> menus do not support custom CSS padding on their internal <option> tags across all web browsers. Therefore, we must rely on the hyphenated string (hierarchyName) we built in Java.

  • Open createOrUpdateLocationForm.html and change th:text="${loc.name}" to th:text="${loc.hierarchyName}":

Update the Dropdown Menu

        <option th:each="loc : ${parentLocations}" 
              th:value="${loc.id}" 
              th:text="${loc.hierarchyName}" 
              th:data-address="${loc.address ?: ''}"
              th:data-lat="${loc.latitude ?: ''}"
              th:data-lng="${loc.longitude ?: ''}">Parent Name</option>
  • HTML Tables support full CSS. Rather than printing hyphens, we can use the integer depth to apply an elegant CSS padding-left to the table cell, physically indenting the text based on its depth.

  • Open schoolDetails.html and update your table body:

Update the Table

    <table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
      <tbody>
        <tr th:each="location : ${school.sortedHierarchy}">
          
          <td th:style="'padding-left: ' + (${location.depth} * 2) + 'rem;'">
            <span th:if="${location.depth > 0}" class="text-muted me-1">&#8627;</span>
            <span th:text="${location.name}">Building Name</span>
          </td>
          
          <td th:text="${location.address}">123 Main St</td>

Azure Email Communication

  • Here is a visualization of how these two new columns power the security of the reset process.

Password Reset Token Flow

sequenceDiagram
    participant U as User
    participant C as AuthController
    participant DB as User Database
    participant E as Azure Email

    %% Forgot Password Phase
    U->>C: POST /forgot-password (Email)
    C->>DB: Find User by Email
    alt User Exists
        C->>C: Generate UUID Token
        C->>DB: Save reset_token & expires_at (+15 mins)
        C->>E: Send Email with ?token=UUID
    end
    C-->>U: "Check your email" (Always returns this)

    %% Reset Phase
    U->>C: GET /reset-password?token=UUID (Clicks Link)
    C->>DB: Find User by reset_token
    DB-->>C: Returns User Data
    C->>C: Check if current time < expires_at
    alt Token Valid
        C-->>U: Show New Password Form
    else Token Expired / Invalid
        C-->>U: Redirect to Login with Error
    end

    %% Final Submission
    U->>C: POST /reset-password (Token + New Password)
    C->>DB: Find User by reset_token
    C->>C: Hash New Password
    C->>DB: Update password_hash
    C->>DB: Set reset_token & expires_at to NULL
    C-->>U: Success! Redirect to Login
  • Open your loginForm.html file and add the "Forgot Password" link right next to your registration link.

Update the Login Form

    <div class="d-grid gap-2 mt-4">
      <button type="submit" class="btn btn-primary">Log In</button>
    </div>

    <div class="text-center mt-3">
      <p><a th:href="@{/forgot-password}">Forgot your password?</a></p>
      <p>Don't have an account? <a th:href="@{/register-student}">Register here</a></p>
    </div>
.requestMatchers(
	"/",
	"/register-student",
	"/resources/**",
	"/recipes/**",
	"/recipes/new",
	"/owners/**",
	"/pets/**",
	"/vets/**",
	"/vets.html",
	"/forgot-password",
	"/reset-password"
).permitAll()
  • Allow GET and POST requests to "/forgot-password" and "/reset-password"

Update the SecurityConfig

  • Add a GET route to display a form asking for the user's email address.

Update the AuthController

    @GetMapping("/forgot-password")
    public String showForgotPasswordForm() {
        return "auth/forgotPasswordForm"; 
    }
  • Create a new file named forgotPasswordForm.html in your src/main/resources/templates/auth/ directory.

  • Since this specific form only sends a single email string rather than a complete Java object, it is easier to use standard HTML inputs instead of the Thymeleaf inputField fragment, which expects a full object to bind to.

  • The required and type="email" attributes ensure the browser validates the email format before it even hits your server, saving unnecessary backend processing.

forgotPasswordForm.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'login')}">
<body>

<h2 class="text-center">Forgot Password</h2>

<div class="container mt-4" style="max-width: 500px;">

  <div th:if="${message}" class="alert alert-success" th:text="${message}">
    Message goes here.
  </div>

  <div th:if="${error}" class="alert alert-danger" th:text="${error}">
    Error goes here.
  </div>

  <p class="text-muted text-center mb-4">
    Enter your email address below and we will send you a link to reset your password.
  </p>

  <form th:action="@{/forgot-password}" method="post" class="form-horizontal">

    <div class="mb-3">
      <label for="email" class="form-label">Email Address</label>
      <input type="email" id="email" name="email" class="form-control" required autofocus />
    </div>

    <div class="d-grid gap-2 mt-4">
      <button type="submit" class="btn btn-primary">Send Reset Link</button>
    </div>

    <div class="text-center mt-3">
      <p><a th:href="@{/login}">Back to Login</a></p>
    </div>

  </form>
</div>

</body>
</html>
  • When building the reset link, to handle a dynamic base URL (localhost vs. production) cleanly, the best practice in Spring Boot is to use your application.properties file.

  • Open your src/main/resources/application.properties file and add this line for your local development environment:
    app.base-url=http://localhost:8080

  • When you deploy to Azure, you need to set an environment variable named APP_BASE_URL with the value https://my-project-name.azurecontainerapps.io.

    • Spring Boot is smart enough to automatically override the application.properties value with the environment variable during production.

Building a Reset Link

  • Create a new file named EmailService.java in the same package as your AuthController.

  • This class uses the @Service annotation so Spring Boot can manage it and inject it into your AuthController. The constructor uses the @Value annotation to pull your credentials from the properties file and initialize the Azure EmailClient.

EmailService

package org.springframework.samples.petclinic.user;

import com.azure.communication.email.EmailClient;
import com.azure.communication.email.EmailClientBuilder;
import com.azure.communication.email.models.EmailMessage;
import com.azure.communication.email.models.EmailSendResult;
import com.azure.core.util.polling.SyncPoller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class EmailService {

	private final EmailClient emailClient;
	private final String senderEmail;

	public EmailService(
		@Value("${azure.communication.connection-string}") String connectionString,
		@Value("${azure.communication.sender-email}") String senderEmail) {

		// Initialize the client once when the application starts
		this.emailClient = new EmailClientBuilder()
			.connectionString(connectionString)
			.buildClient();
		this.senderEmail = senderEmail;
	}

	public void sendPasswordResetEmail(String toAddress, String resetLink) {
		String subject = "Password Reset Request";

		String htmlContent = "<html><body>"
			+ "<h2>Password Reset</h2>"
			+ "<p>We received a request to reset your password. Click the link below to set a new password:</p>"
			+ "<p><a href=\"" + resetLink + "\">Reset Password</a></p>"
			+ "<p>If you did not request this, please ignore this email.</p>"
			+ "</body></html>";

		String plainTextContent = "Please reset your password using this link: " + resetLink
			+ "\n\nIf you did not request this, please ignore this email.";

		EmailMessage message = new EmailMessage()
			.setSenderAddress(this.senderEmail)
			.setToRecipients(toAddress)
			.setSubject(subject)
			.setBodyHtml(htmlContent)
			.setBodyPlainText(plainTextContent);

		try {
			// Send the email and wait for the operation to complete
			SyncPoller<EmailSendResult, EmailSendResult> poller = emailClient.beginSend(message, null);
			poller.waitForCompletion();
		} catch (Exception e) {
			// Log the error. In a production environment, use a logger like SLF4J.
			System.err.println("Failed to send email to " + toAddress + ": " + e.getMessage());
		}
	}
}
  • Before we implement the methods to your controller, we need to add a method to the UserRepository so it knows how to find a user by their token.

  • Spring Data JPA will automatically translate this method name into SELECT * FROM users WHERE reset_token = ? 

Update the UserRepository

package org.springframework.samples.petclinic.user;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Integer> {
	Optional<User> findByEmail(String email);

	boolean existsByEmail(String email);

	// Add this new method
	Optional<User> findByResetToken(String resetToken);
}
  • Open your AuthController.java file and add a POST route to process the form.

  • Run the program, submit the "Forgot Password" form withhat  a user that has an email address that you have access to.

Sending a Reset Link

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.time.LocalDateTime;
import java.util.UUID;

@Controller
public class AuthController {
	private final UserService userService;
	private final SchoolRepository schoolRepository;
	private final AuthenticationManager authenticationManager;
	private final UserRepository userRepository;
	private final EmailService emailService; // Your Azure email service

	// Injects the URL from application.properties, defaults to localhost if missing
	@Value("${app.base-url:http://localhost:8080}")
	private String baseUrl;

	// Add to Constructor
	public AuthController(UserService userService, SchoolRepository schoolRepository,
						  AuthenticationManager authenticationManager,
						  UserRepository userRepository, EmailService emailService) {
		this.userService = userService;
		this.schoolRepository = schoolRepository;
		this.authenticationManager = authenticationManager;
		this.userRepository = userRepository;
		this.emailService = emailService;
	}

// -- Other code --

    @PostMapping("/forgot-password")
    public String processForgotPassword(@RequestParam("email") String email, RedirectAttributes redirectAttributes) {
        
        Optional<User> userOptional = userRepository.findByEmail(email);
        
        if (userOptional.isPresent()) {
            User user = userOptional.get();
            
            // 1. Generate a secure token
            String token = UUID.randomUUID().toString();
            
            // 2. Save this token and an expiration timestamp (15 minutes from now)
            user.setResetToken(token);
            user.setResetTokenExpiresAt(LocalDateTime.now().plusMinutes(15));
            userRepository.save(user);
            
            // 3. Build the dynamic reset link
            String resetLink = baseUrl + "/reset-password?token=" + token;
            
            // 4. Use your Azure service to email the link
            // Assuming your service has a method like this:
            emailService.sendPasswordResetEmail(user.getEmail(), resetLink);
        }

        // Always return a generic success message to prevent "email enumeration" security risks.
        // (Hackers can't use this form to guess which emails are registered in your database).
        redirectAttributes.addFlashAttribute("messageSuccess", "If an account with that email exists, a password reset link has been sent.");
        return "redirect:/login";
    }
  • To make this controller work, your database needs needs to temporarily remember the unique token and explicitly enforce an expiration time (usually 15 to 60 minutes).

  • Here is how to update your schema to add the token tracking columns to your existing MySQL database. Adding an index to the token column is highly recommended, as your AuthController will need to look up users by this exact string when they click the link in their email.

Update the User database table

-- Add the token and expiration columns
ALTER TABLE users ADD COLUMN reset_token VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN reset_token_expires_at DATETIME NULL;

-- Add an index for fast lookups during the reset phase
CREATE UNIQUE INDEX idx_users_reset_token ON users (reset_token);
  • Here is how to update your Java entity to add the corresponding fields to map the new columns.

  • By explicitly setting the reset_token and reset_token_expires_at to null the moment the password is successfully changed, you ensure that the emailed link instantly becomes a dead link, preventing re-uses or exploits.

Update the User entity class

    @Column(name = "reset_token", unique = true)
    private String resetToken;

    @Column(name = "reset_token_expires_at")
    private LocalDateTime resetTokenExpiresAt;

    // Optional helper methods for cleaner controller logic
    public boolean isResetTokenValid() {
        return this.resetTokenExpiresAt != null && LocalDateTime.now().isBefore(this.resetTokenExpiresAt);
    }
    
    public void clearResetToken() {
        this.resetToken = null;
        this.resetTokenExpiresAt = null;
    }
  • Add a GET and POST route to display and process a form asking for the user's new password.

Update the AuthController

    @GetMapping("/reset-password")
    public String showResetPasswordForm(@RequestParam("token") String token, Model model, RedirectAttributes redirectAttributes) {
        
        // TODO: Look up the user by the provided token
        // If the token is invalid or expired, redirect to the login page with a flash message.
        // If valid, pass the token to the view so the form can submit it back
        
        return "auth/resetPasswordForm";
    }

    @PostMapping("/reset-password")
    public String processResetPassword(@RequestParam("token") String token, 
                                       @RequestParam("password") String newPassword, 
                                       RedirectAttributes redirectAttributes) {
        
        // TODO: Look up the user by the token
        // 1. Hash the newPassword using your PasswordEncoder
        // 2. Clear the reset token from the database so it cannot be used again
        // 3. Update the user's password and token in the database
        
        return "redirect:/login";
    }
  • Open your AuthController.java file and replace your existing placeholder method with this complete implementation that will be called when a user clicks the link in their email.

  • This code will catch the token from the URL (?token=ed2a...), look it up in the database, and use the isResetTokenValid() helper method we added to your User entity to ensure the 15-minute window hasn't expired.

Implement the GET Method

    @GetMapping("/reset-password")
    public String showResetPasswordForm(@RequestParam("token") String token, Model model, RedirectAttributes redirectAttributes) {
        
        Optional<User> userOptional = userRepository.findByResetToken(token);

        if (userOptional.isEmpty() || !userOptional.get().isResetTokenValid()) {
            redirectAttributes.addFlashAttribute("messageDanger", "Your password reset link is invalid or has expired. Please request a new one.");
            return "redirect:/login";
        }

        // We will put this in a hidden <input> field on the next HTML page.
        model.addAttribute("token", token);
        
        return "auth/resetPasswordForm";
    }
  • Create a new file named resetPasswordForm.html in your src/main/resources/templates/auth/ directory.

  • Since this form only sends a single password string rather than a complete Java object, it is easier to use standard HTML inputs instead of the inputField fragment, which expects a full object.

  • The input tag's pattern attribute ensures the browser validates the format before submitting to your server.

  • If the password reset token hasn't expired, this form will send the new password, along with the secure token, back to your server.

resetPasswordForm.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'login')}">
<body>

<h2 class="text-center">Set New Password</h2>

<div class="container mt-4" style="max-width: 500px;">

  <p class="text-muted text-center mb-4">
    Please enter your new password below.
  </p>

  <form th:action="@{/reset-password}" method="post" class="form-horizontal">

    <input type="hidden" name="token" th:value="${token}" />

    <div class="mb-3">
      <label for="password" class="form-label">New Password</label>
      <input type="password" 
             id="password" 
             name="password" 
             class="form-control" 
             required 
             minlength="8" 
             pattern="^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$"
             title="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number."
             autofocus />
      <div class="form-text">
        Must be at least 8 characters and contain an uppercase letter, a lowercase letter, and a number.
      </div>
    </div>

    <div class="d-grid gap-2 mt-4">
      <button type="submit" class="btn btn-primary">Update Password</button>
    </div>

  </form>
</div>

</body>
</html>
  • When the user clicks "Update Password," the form submits the hidden token and the new password via a POST request.

  • The implementation in the final method in your AuthController hashes the new password and clears the token so the link becomes invalid.

  • You will need to inject your PasswordEncoder into your controller's constructor alongside the others.

Processing the Reset

import org.springframework.security.crypto.password.PasswordEncoder;

// ... Other imports ...

@Controller
public class AuthController {
	private final UserService userService;
	private final SchoolRepository schoolRepository;
	private final AuthenticationManager authenticationManager;
	private final UserRepository userRepository;
	private final EmailService emailService;
	private final PasswordEncoder passwordEncoder;


	// Add to Constructor
	public AuthController(UserService userService, SchoolRepository schoolRepository,
						  AuthenticationManager authenticationManager,
						  UserRepository userRepository, EmailService emailService,
						  PasswordEncoder passwordEncoder) {
		this.userService = userService;
		this.schoolRepository = schoolRepository;
		this.authenticationManager = authenticationManager;
		this.userRepository = userRepository;
		this.emailService = emailService;
		this.passwordEncoder = passwordEncoder;
	}

    // ... Other methods ...

    @PostMapping("/reset-password")
    public String processResetPassword(@RequestParam("token") String token, 
                                       @RequestParam("password") String newPassword, 
                                       RedirectAttributes redirectAttributes) {
        
        Optional<User> userOptional = userRepository.findByResetToken(token);

        // Perform a final security check in case the token expired while they were typing
        if (userOptional.isEmpty() || !userOptional.get().isResetTokenValid()) {
            redirectAttributes.addFlashAttribute("error", "Your session expired. Please request a new link.");
            return "redirect:/login";
        }

        User user = userOptional.get();

        user.setPassword(passwordEncoder.encode(newPassword));

        user.clearResetToken();

        userRepository.save(user);
        
        redirectAttributes.addFlashAttribute("messageSuccess", "Your password has been reset. Please log in.");
        return "redirect:/login";
    }
  • Once this is saved, your complete end-to-end password reset flow should be functional. The user can request a link, receive the email, click the tokenized URL, and securely update their credentials.

  • To inject an environment variable into your Azure Container App, append the --set-env-vars parameter to your existing command.
    az containerapp update `
        --name <your-container-app-name> `
        --resource-group <your-resource-group-name> `
        --image <your-docker-username>/<your-imagename>:<your-version> `
        --set-env-vars AZURE_COMM_CONNECTION=<your-azure-communication-connection-string>

Processing the Reset

  • X

More Create Table Statements

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)
);
  • X

More Create Table Statements

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)
);
  • X

More Create Table Statements

DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id_from INT NOT NULL,
    user_id_to INT,
    league_id INT,
    event_id INT,
    parent_message_id INT, -- For threaded replies
    message VARCHAR(255) NOT NULL,
    is_flagged TINYINT DEFAULT 0,
    status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
    CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,

    -- Performance: Quickly load chat history for a league or event
    INDEX idx_messages_context (league_id, event_id)
);

DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    message_id INT NOT NULL,
    user_id INT NOT NULL,
    reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: One reaction per user per message
    UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);
  • It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.

  • This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.

  • Here is how that transition works:

    • Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.

    • React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.

    • https://github.com/spring-petclinic/spring-petclinic-rest

Frontend JavaScript Frameworks

  • 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:

Frontend JavaScript Frameworks

// 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.

Frontend JavaScript Frameworks

  • Maven Run Configuration

    You can save this command as a clickable button within the IDE:

  • Go to Run > Edit Configurations....
  • Click the + (Add New Configuration) button and select Maven.
  • Name it something like App Run (Maven).
  • In the Command line field, enter:
    spring-boot:run
  • Click OK. You can now run or debug this configuration from the top toolbar. [1, 2, 3, 4, 5]

Frontend JavaScript Frameworks

Java 3 - Weeks 13-14

By Marc Hauschildt

Java 3 - Weeks 13-14

  • 68