Role-Based AI Inventory Assistant

Build a Gemini-powered Spring Boot API with JWT auth and role-based prompts.

Introduction

30 Second Summary

Picture a company chatbot where anyone can type a question. The challenge is making the AI give completely different answers to different people based on their role.

In this project, you will build an AI-powered inventory assistant using Spring Boot and Google Gemini. You will wire JWT authentication and role-based prompt engineering so the same AI model gives different answers depending on who is logged in.

What You'll Build

You will log in as different users in a browser chat interface and watch Gemini adapt its inventory knowledge in real time, giving admins full system reports while restricting customers to only their own order history.

By the end of this project, you'll have:

  • Log in through a JWT-secured API and chat with a Gemini-powered assistant that dynamically adjusts its knowledge based on your role.
  • Ask about low-stock products as an admin and see the AI flag specific items with real quantities pulled from your MySQL database.
  • Switch to a customer account and watch the same AI restrict itself to only your order history, refusing to reveal system-wide data or other customers' information.
  • Secret Mission: Add conversation memory so the chatbot remembers previous messages within a session.

Are there any prerequisites?

This project assumes you have Java 21, IntelliJ IDEA, and a MySQL server running locally. You will also need a Google account to generate a free Gemini API key in Step 1.

Before We Start

Before you start building, take a moment to lock in what you're about to create and why it matters.

Set Up the Project and API Key

Every Spring Boot AI project starts with two foundations: a project skeleton wired with the right dependencies, and an API key that lets your code call an AI model.

In this step, you'll generate a Gemini API key, scaffold a Spring Boot project with Spring AI and JWT libraries, and confirm the app starts against your local MySQL database.

In this step, get ready to:
  • Generate a Gemini API key from Google AI Studio.
  • Create a Spring Boot project with Spring AI and JWT dependencies.
  • Configure your database connection and run the app.
Generate your Gemini API key

Google Gemini is the AI model your inventory assistant will call. To connect your app to Gemini, you need an API key from Google AI Studio.

  • Open https://aistudio.google.com in your browser.
  • Sign in with your Google account.
  • Click Get API Key in the left sidebar.
  • Click Create API key.
  • Copy the key and save it somewhere safe. You will paste it into your app's configuration shortly.

Is the Gemini API free?

The Gemini API free tier charges nothing for input and output tokens. This project stays well within the free allowance, so you will not be billed.

Content sent on the free tier is used to improve Google products. Keep that in mind if you test with sensitive data.

Keep your API key private

Treat your API key like a password. Do not commit it to version control or share it publicly.

Generate and configure the Spring Boot project

Spring Initializr is a web tool that scaffolds Spring Boot projects with your chosen dependencies. You will generate the base project here, then add two more libraries manually.

  • Open https://start.spring.io in your browser.
  • Confirm Maven is selected under Project.
  • Confirm Java is selected under Language.
  • Select 4.1.0 (or the latest 4.1.x version) under Spring Boot.
  • Enter com.inventory in the Group field.
  • Enter assistant in the Artifact field.
  • Enter inventory-assistant in the Name field.
  • Confirm Java is set to 21 under the Java version selector.

What about the other fields?

The defaults for Packaging (Jar) and Description are fine as-is. No changes needed there.

  • Click Add Dependencies.
  • Search for Spring Web and select it from the results.
  • Search for Spring Data JPA and add it.
  • Search for Spring Security and add it.
  • Search for MySQL Driver and add it.
  • Click Generate to download the ZIP file.
  • Extract the downloaded ZIP file to a location you can find easily.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • Click Open (or File then Open).
  • Select the extracted assistant folder and click OK.
  • Wait for IntelliJ to import the Maven project. You should see the project structure appear in the Project panel on the left.

The generated project includes Spring Web, JPA, Spring Security, and MySQL. Now you need to add two more libraries that start.spring.io does not list: the Spring AI Google GenAI starter and JJWT for JWT token handling.

  • Open pom.xml from the project root in IntelliJ's editor.
  • Find the closing </properties> tag.
  • Add the Spring AI BOM block directly after </properties> by pasting the following code:
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>2.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

What is a BOM?

A BOM (Bill of Materials) manages dependency versions in one place. By importing the Spring AI BOM at version 2.0.0, you do not need to specify a version number on each individual Spring AI starter you add.

  • Scroll down to the <dependencies> section in the same pom.xml file.
  • Add the Spring AI starter and JJWT dependencies after the last existing </dependency> tag by pasting this code:
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-google-genai</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.13.0</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.13.0</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.13.0</version>
            <scope>runtime</scope>
        </dependency>

What do these dependencies do?

spring-ai-starter-model-google-genai connects your app to Google Gemini through Spring AI's ChatClient API. It auto-configures a ChatModel bean so you can send prompts and receive responses.

jjwt-api, jjwt-impl, and jjwt-jackson (all at version 0.13.0) provide JWT creation and validation. You will use them to secure your API endpoints later.

  • Save pom.xml.
  • Click the Load Maven Changes notification that appears in the editor, or right-click pom.xml in the Project panel and select Maven then Reload project.
  • Wait for IntelliJ to download the new dependencies. You should see no errors in the Build output panel.

Dependencies not resolving?

  • Make sure you added the <dependencyManagement> block between </properties> and <dependencies>. It must not be nested inside <dependencies>.
  • Check that the Spring AI BOM version is 2.0.0 and all three JJWT artifacts use version 0.13.0.
  • Verify your internet connection. Maven needs to download the new dependencies from the remote repository.

help me fix Maven dependency errors in my Spring Boot pom.xml

✔️ Awesome, I've got everything!

Great. Double check you have saved pom.xml and the Maven reload completed without errors.

ⓧ I'd like to double check the full code

The full pom.xml is too long to show in one block. Scroll up and confirm these two additions are in place:

  • The <dependencyManagement> block (Spring AI BOM at 2.0.0) sits between </properties> and <dependencies>.
  • Four new <dependency> entries (spring-ai-starter-model-google-genai, jjwt-api, jjwt-impl, jjwt-jackson) are inside the <dependencies> section alongside the four generated starters.
Configure the database and run the app

Your Spring Boot app needs a MySQL database to store users, products, and orders. You will create an empty database now and let JPA build the tables automatically when the app starts.

  • Open a terminal window.
  • Connect to MySQL and create the database by running this command:
mysql -u root -p -e "CREATE DATABASE inventory_assistant;"

Enter your MySQL root password when prompted. If the command completes without errors, the database was created.

  • Confirm the database exists by running:
mysql -u root -p -e "SHOW DATABASES;"

You should see inventory_assistant in the list of databases.

Command not found or access denied?

  • If mysql is not found, your MySQL client may not be on your system PATH. Try using the full path to the mysql binary, or open MySQL Workbench instead and run CREATE DATABASE inventory_assistant; there.
  • If you see an access denied error, double check you are using the correct root password for your local MySQL server.

help me connect to my local MySQL server to create a database

Now configure your app to connect to this database and to use your Gemini API key.

  • In IntelliJ, open src/main/resources/application.properties.
  • You should see a single line: spring.application.name=inventory-assistant. Add the following properties below that line:
spring.datasource.url=jdbc:mysql://localhost:3306/inventory_assistant
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

spring.ai.google.genai.api-key=your-gemini-api-key-here

jwt.secret=c3ByaW5nLWFpLWludmVudG9yeS1hc3Npc3RhbnQtand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50
jwt.expiration=86400000

What do these properties do?

  • spring.datasource.* tells Spring Boot how to connect to your local MySQL database. Replace your-mysql-password-here with your actual MySQL root password.
  • spring.jpa.hibernate.ddl-auto=create-drop recreates database tables every time the app starts. This is perfect for a learning project because it resets your data to a clean state.
  • spring.ai.google.genai.api-key is where you paste the Gemini API key you generated earlier. Replace your-gemini-api-key-here with your actual key.
  • jwt.secret is a Base64-encoded string used to sign JWT tokens. The provided value works for development.
  • jwt.expiration sets tokens to expire after 24 hours (86,400,000 milliseconds).
  • Replace your-mysql-password-here with your actual MySQL root password.
  • Replace your-gemini-api-key-here with the Gemini API key you copied from Google AI Studio.
  • Save application.properties.

✔️ Awesome, I've got everything!

Great. Make sure you have replaced both placeholder values with your real credentials before running the app.

ⓧ I'd like to double check the full code

Your complete application.properties should look like this (with your own password and API key in place of the placeholders):

spring.application.name=inventory-assistant

spring.datasource.url=jdbc:mysql://localhost:3306/inventory_assistant
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

spring.ai.google.genai.api-key=your-gemini-api-key-here

jwt.secret=c3ByaW5nLWFpLWludmVudG9yeS1hc3Npc3RhbnQtand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50
jwt.expiration=86400000

Before you run the app, what do you think will happen? The project has Spring Security on the classpath but no custom security configuration yet.

  • In IntelliJ, open src/main/java/com/inventory/assistant/InventoryAssistantApplication.java.
  • Click the green play button in the gutter next to public static void main and select Run.

Watch the IntelliJ console output. You should see a line that says Started InventoryAssistantApplication near the end of the log.

What about the generated security password?

You will also see a message like Using generated security password: 78fa095d-3f4c-... in the console. This is normal. Spring Security creates a default user when no custom security config exists. You will replace this with your own JWT-based security in Step 4.

App fails to start?

  • If you see a connection refused error mentioning port 3306, confirm your MySQL server is running locally.
  • If you see an access denied error, double check the spring.datasource.username and spring.datasource.password values in application.properties.
  • If you see an unknown database error, make sure you ran the CREATE DATABASE inventory_assistant command earlier.
  • If you see a dependency error, re-run the Maven reload in IntelliJ and check for typos in pom.xml.

help me fix my Spring Boot app startup error

Your project skeleton is running and wired to both MySQL and Gemini. Next up, you will build your first AI chat endpoint and see Gemini respond to a question.

Get AI Chatting

Your Spring Boot project is running with all the dependencies wired up and your Gemini API key configured. The foundation is solid, but the app does not talk to Gemini yet.

Before adding users, inventory data, or authentication, you need to prove one thing works: sending a message to Gemini and getting a response. In this step, you will create a minimal chat endpoint that connects directly to Google's Gemini model through Spring AI.

In this step, get ready to:
  • Create a ChatService that communicates with Gemini through Spring AI.
  • Build a POST /api/chat endpoint that accepts messages and returns AI responses.
  • Test the connection with curl to confirm Gemini responds.
Create a ChatService to talk to Gemini

Spring AI provides a ChatClient that handles the communication with AI models like Gemini. Spring Boot auto-configures a ChatClient.Builder bean when the Google GenAI starter is on the classpath, so you do not need to set up the connection manually.

  • In IntelliJ, right-click the com.inventory.assistant package in the Project panel.
  • Select New > Java Class and name it service.ChatService. IntelliJ creates the service subpackage automatically.
  • Replace the contents of the file with the following code:
package com.inventory.assistant.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class ChatService {

    // ChatClient handles communication with Gemini
    private final ChatClient chatClient;

    // Spring Boot auto-configures a ChatClient.Builder when the GenAI starter is present
    public ChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    // Send the user's message to Gemini and return the AI's response
    public String chat(String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

What does this code do?

The constructor receives a ChatClient.Builder that Spring Boot auto-configures from your spring.ai.google.genai.api-key property. Calling .build() creates a ready-to-use ChatClient wired to Gemini.

The chat() method uses a fluent API chain: .prompt() starts a new conversation, .user(message) sets the user's question, .call() sends it to Gemini, and .content() extracts the text response.

  • Save ChatService.java.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

package com.inventory.assistant.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class ChatService {

    private final ChatClient chatClient;

    public ChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    public String chat(String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}
Create the chat endpoint and disable security

Your service can talk to Gemini, but there is no way for the outside world to reach it yet. You need a controller that exposes an HTTP endpoint, plus a small DTO to deserialize the incoming JSON.

  • Right-click the com.inventory.assistant package, select New > Java Class, and name it dto.ChatRequest.
  • Replace the contents with the following code:
package com.inventory.assistant.dto;

// A record that maps the incoming JSON body to a message field
public record ChatRequest(String message) {
}
  • Create another class under com.inventory.assistant named controller.ChatController by right-clicking the base package and selecting New > Java Class.
  • Replace the contents with the following code:
package com.inventory.assistant.controller;

import com.inventory.assistant.dto.ChatRequest;
import com.inventory.assistant.service.ChatService;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatService chatService;

    // Spring injects the ChatService bean automatically
    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    // POST /api/chat accepts a JSON body with a "message" field
    @PostMapping("/chat")
    public Map<String, String> chat(@RequestBody ChatRequest request) {
        // Forward the message to ChatService and return the response
        String response = chatService.chat(request.message());
        return Map.of("response", response);
    }
}

What does this code do?

ChatRequest is a Java record that automatically deserializes the JSON body into a message field. Records are a compact way to define simple data carriers in Java.

ChatController maps POST /api/chat to the chat() method. It passes the user's message to ChatService and wraps the AI response in a JSON map with a response key.

  • Save both ChatRequest.java and ChatController.java.
  • Restart the app in IntelliJ by clicking the Rerun button (the green circular arrow in the Run panel at the bottom).

Watch the IntelliJ console. The app starts, but notice a line that says Using generated security password: followed by a random string.

Why is there a generated password?

Spring Security is on the classpath and locks down every endpoint by default. It creates a random password so you can log in during development. Your curl requests would need that password to reach /api/chat, which is not what you want right now.

You will add real JWT authentication in Step 4. For now, create a temporary security config that permits all requests so you can test freely.

  • Create a new class named security.SecurityConfig under the com.inventory.assistant package.
  • Replace the contents with the following temporary security configuration:
package com.inventory.assistant.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Temporarily permit all requests so we can test the chat endpoint
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            );
        return http.build();
    }
}

What does this config do?

This replaces Spring Security's default "lock everything down" behavior. It disables CSRF protection (required for testing POST requests with curl) and permits all requests without authentication.

This is temporary. You will replace this file with proper JWT-based security in Step 4.

  • Save SecurityConfig.java.
  • Restart the app again by clicking Rerun in the IntelliJ Run panel.

This time the console should show Started InventoryAssistantApplication with no generated security password line. The endpoint is now open for testing.

App won't start?

  • Check the IntelliJ console for a red stack trace. A BeanCreationException often means a typo in a class name or a missing import.
  • Make sure SecurityConfig.java is in the com.inventory.assistant.security package. Spring Boot only scans packages under com.inventory.assistant.
  • Verify your MySQL database inventory_assistant still exists and MySQL is running.

Help me debug my Spring Boot startup error.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full ChatController.java for comparison:

package com.inventory.assistant.controller;

import com.inventory.assistant.dto.ChatRequest;
import com.inventory.assistant.service.ChatService;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatService chatService;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping("/chat")
    public Map<String, String> chat(@RequestBody ChatRequest request) {
        String response = chatService.chat(request.message());
        return Map.of("response", response);
    }
}
Test the AI connection with curl

The endpoint is live and security is open. Time to prove Gemini is actually responding.

Before you run the next command, think about what you expect to get back. You are sending a plain text question to Gemini through your new endpoint. What shape will the response take?

  • Open a terminal window outside of IntelliJ (or use IntelliJ's built-in Terminal tab at the bottom of the window).
  • Send a test message to your chat endpoint by running this command:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"What is inventory management?\"}"

You should see a JSON response from Gemini explaining what inventory management is. The response comes back as {"response":"..."} because your controller wraps it in a Map.

Prefer Postman?

If you have Postman installed, create a POST request to http://localhost:8080/api/chat with a JSON body {"message": "What is inventory management?"}. The response appears formatted in the Postman response panel.

  • Now try an inventory-related question to see domain-relevant output by running:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"How should I track product stock levels?\"}"

Gemini responds with practical advice about tracking stock levels. Right now the AI gives generic answers because it has no data about your inventory. You will fix that in the coming steps by feeding it real product and order data.

Getting an error response?

  • If you see Connection refused, make sure the Spring Boot app is still running in IntelliJ.
  • If you see a 403 or 401 status, your SecurityConfig may not be in the right package. Confirm it is under com.inventory.assistant.security.
  • If you see an API key error, double-check that spring.ai.google.genai.api-key in application.properties contains your actual Gemini API key.

Help me fix my Gemini API connection.

Your AI integration is proven. Gemini responds to any question you throw at it. Next up, you will build the user and inventory data layer so the AI has real data to reason about.

Build the User and Inventory Data Layer

Your AI chatbot can talk about inventory, but everything it says is generic. It has no real users, products, or orders to reason about.

In this step, you will create the data layer that gives the AI real context. You will define JPA entities for users with roles, products with stock levels, and customer orders. Then you will seed the database with sample data so the AI has something concrete to work with in later steps.

In this step, get ready to:
  • Create JPA entities for users with roles, products, and customer orders.
  • Create Spring Data JPA repositories for each entity.
  • Seed the database with sample users, products, and orders on startup.
Create the JPA entities

JPA entities are Java classes that map directly to database tables. Each field becomes a column, and Hibernate creates the tables automatically when the app starts.

  • In IntelliJ, right-click the com.inventory.assistant package in the Project sidebar.
  • Select New then Package, and name it model.
  • Create a new Java file called Role.java inside the model package with this code:
package com.inventory.assistant.model;

public enum Role {
    ADMIN,
    CUSTOMER
}
  • Create another Java file called User.java in the same model package with this entity definition:
package com.inventory.assistant.model;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;
}

What do these JPA annotations do?

  • @Entity tells Hibernate this class maps to a database table.
  • @Table(name = "users") sets the table name explicitly, since user is a reserved word in some SQL dialects.
  • @Id and @GeneratedValue mark the primary key and tell the database to auto-increment it.
  • @Enumerated(EnumType.STRING) stores the Role enum as a readable string (ADMIN or CUSTOMER) rather than a number.
  • Save both files and restart the application in IntelliJ by clicking the green Rerun button in the Run panel.
  • Check the console output. You should see Hibernate SQL including create table users with columns for id, username, password, and role.

Don't see the table creation SQL?

  • Confirm spring.jpa.show-sql=true is set in your application.properties. This enables Hibernate SQL logging.
  • Make sure your inventory_assistant MySQL database exists and the connection URL, username, and password in application.properties are correct.
  • Check that Role.java is in the model package. User.java references it, so a missing or misplaced Role class will cause a compilation error.

Still stuck? Help me debug why Hibernate isn't creating my users table in the console output.

  • Now add constructors and accessor methods to User.java. Place the following code inside the class, just before the closing }:
    public User() {}

    public User(String username, String password, Role role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public Role getRole() { return role; }
    public void setRole(Role role) { this.role = role; }
  • Save User.java.
  • Create a new file called Product.java in the model package with this entity definition:
package com.inventory.assistant.model;

import jakarta.persistence.*;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String category;

    @Column(nullable = false)
    private Double price;

    @Column(nullable = false)
    private Integer stockQuantity;
}
  • Restart the application.
  • You should now also see create table products in the Hibernate SQL output alongside the users table.
  • Add constructors and accessor methods to Product.java before the closing }:
    public Product() {}

    public Product(String name, String category, Double price, Integer stockQuantity) {
        this.name = name;
        this.category = category;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
  • Save Product.java.
  • Create a new file called CustomerOrder.java in the model package with this entity definition:
package com.inventory.assistant.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "customer_orders")
public class CustomerOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false)
    private Double totalPrice;

    @Column(nullable = false)
    private LocalDateTime orderDate;
}

Why CustomerOrder instead of Order?

ORDER is a reserved keyword in SQL. Naming the entity CustomerOrder and mapping it to the customer_orders table avoids conflicts with the SQL parser.

The @ManyToOne annotations create foreign key relationships. Each order belongs to one user and references one product. Hibernate generates the user_id and product_id foreign key columns automatically.

  • Restart the application.
  • You should now see create table customer_orders in the Hibernate output, with foreign key constraints pointing to users and products.
  • Add constructors and accessor methods to CustomerOrder.java before the closing }:
    public CustomerOrder() {}

    public CustomerOrder(User user, Product product, Integer quantity, Double totalPrice, LocalDateTime orderDate) {
        this.user = user;
        this.product = product;
        this.quantity = quantity;
        this.totalPrice = totalPrice;
        this.orderDate = orderDate;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public Double getTotalPrice() { return totalPrice; }
    public void setTotalPrice(Double totalPrice) { this.totalPrice = totalPrice; }
    public LocalDateTime getOrderDate() { return orderDate; }
    public void setOrderDate(LocalDateTime orderDate) { this.orderDate = orderDate; }
  • Save CustomerOrder.java.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved: Role.java, User.java, Product.java, and CustomerOrder.java.

ⓧ I'd like to double check the full code

Each entity file is built across two code blocks (fields, then constructors and accessors). Scroll up and confirm each file has these sections in order:

  • Package declaration and imports.
  • @Entity and @Table annotations.
  • Field declarations with column annotations (and @ManyToOne for CustomerOrder).
  • A no-arg constructor and an all-args constructor.
  • Getter and setter methods for every field.
Create Spring Data JPA repositories

Spring Data JPA generates full database access implementations from simple interfaces. You declare the methods you need, and Spring writes the SQL behind the scenes.

  • Right-click the com.inventory.assistant package and create a new package called repository.
  • Create UserRepository.java inside the repository package:
package com.inventory.assistant.repository;

import com.inventory.assistant.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
  • Create ProductRepository.java in the same package:
package com.inventory.assistant.repository;

import com.inventory.assistant.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}
  • Save both files and restart the application to confirm Spring detects the repositories.
  • The app should start without errors. If you see an error about missing beans or entity scanning, double-check that the repository and model packages are under com.inventory.assistant.
  • Create CustomerOrderRepository.java in the repository package:
package com.inventory.assistant.repository;

import com.inventory.assistant.model.CustomerOrder;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {
    List<CustomerOrder> findByUserId(Long userId);
}

What is Spring Data JPA doing here?

You declared interfaces, not classes. Spring Data JPA generates the implementation at runtime. The JpaRepository base interface provides findAll(), save(), deleteById(), and more out of the box.

Custom methods like findByUsername() and findByUserId() follow a naming convention. Spring parses the method name, generates the correct SQL query, and returns the result.

  • Save CustomerOrderRepository.java.
Seed the database with sample data

An empty database is not useful for testing. A CommandLineRunner runs code automatically when the application starts, which is the perfect place to insert sample data.

  • Right-click the com.inventory.assistant package and create a new package called config.
  • Create DataSeeder.java inside the config package with the class structure and constructor:
package com.inventory.assistant.config;

import com.inventory.assistant.model.*;
import com.inventory.assistant.repository.*;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class DataSeeder implements CommandLineRunner {

    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final CustomerOrderRepository orderRepository;
    private final PasswordEncoder passwordEncoder;

    public DataSeeder(UserRepository userRepository, ProductRepository productRepository,
                      CustomerOrderRepository orderRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
        this.passwordEncoder = passwordEncoder;
    }
  • Add the run() method below the constructor, then close the class. This method creates users with hashed passwords, products with stock levels, and orders linking customers to products:
    @Override
    public void run(String... args) {
        User admin = userRepository.save(
                new User("admin", passwordEncoder.encode("admin123"), Role.ADMIN));
        User customer1 = userRepository.save(
                new User("customer1", passwordEncoder.encode("cust123"), Role.CUSTOMER));
        User customer2 = userRepository.save(
                new User("customer2", passwordEncoder.encode("cust456"), Role.CUSTOMER));

        Product mouse = productRepository.save(
                new Product("Wireless Mouse", "Electronics", 29.99, 150));
        Product hub = productRepository.save(
                new Product("USB-C Hub", "Electronics", 49.99, 8));
        Product desk = productRepository.save(
                new Product("Standing Desk", "Furniture", 399.99, 25));
        Product keyboard = productRepository.save(
                new Product("Mechanical Keyboard", "Electronics", 89.99, 3));
        Product monitorArm = productRepository.save(
                new Product("Monitor Arm", "Furniture", 59.99, 45));

        orderRepository.save(new CustomerOrder(
                customer1, mouse, 2, 59.98, LocalDateTime.now().minusDays(5)));
        orderRepository.save(new CustomerOrder(
                customer1, desk, 1, 399.99, LocalDateTime.now().minusDays(3)));
        orderRepository.save(new CustomerOrder(
                customer2, hub, 3, 149.97, LocalDateTime.now().minusDays(7)));
        orderRepository.save(new CustomerOrder(
                customer2, keyboard, 1, 89.99, LocalDateTime.now().minusDays(1)));

        System.out.println("Database seeded with sample users, products, and orders.");
    }
}

What does the seeder do?

  • Creates three users: admin (ADMIN role), customer1, and customer2 (CUSTOMER role). Passwords are hashed with BCrypt so they are never stored as plain text.
  • Creates five products, two of which have low stock (USB-C Hub at 8 units and Mechanical Keyboard at 3 units). These low-stock items will be important when the AI flags inventory alerts in a later step.
  • Creates four orders spread across customer1 and customer2. Each order links a user to a product with a quantity and total price.

Before you restart, what do you expect to see in the console output this time? Will it just create tables, or will you see something more?

  • Save DataSeeder.java and restart the application.

This time, the console shows Hibernate create table statements followed by insert into statements for each user, product, and order. At the end, you should see the message Database seeded with sample users, products, and orders. printed in the console.

Seeing an error about PasswordEncoder?

If you see an error like No qualifying bean of type PasswordEncoder, it means no PasswordEncoder bean is defined in your application. The DataSeeder needs one to hash passwords.

Make sure the security configuration you set up in the previous step includes a @Bean method returning a BCryptPasswordEncoder. If it does not, add one to your security config class.

Still stuck? Help me fix a missing PasswordEncoder bean error in my Spring Boot app.

  • To fully verify the seed data, open your MySQL client and run these queries against the inventory_assistant database:
SELECT * FROM users;
SELECT * FROM products;
SELECT * FROM customer_orders;
  • You should see 3 users (with hashed passwords), 5 products, and 4 orders in the results.

✔️ Awesome, I've got everything!

Great. Make sure DataSeeder.java is saved and the console shows the seed confirmation message.

ⓧ I'd like to double check the full code

The DataSeeder file is built across two code blocks. Scroll up and confirm your file has these sections in order:

  • Package declaration, imports (including com.inventory.assistant.model.* and com.inventory.assistant.repository.*).
  • @Component annotation on the class, implementing CommandLineRunner.
  • Four final fields injected via the constructor (three repositories plus PasswordEncoder).
  • The run() method saving 3 users, 5 products, and 4 orders.
  • A System.out.println confirmation at the end of run().

Your data layer is ready. You have users with roles, products with stock levels, and orders tying customers to products. Next up, you will lock down the API with JWT authentication so the system knows who is asking each question.

Lock Down the API with JWT Authentication

Your /api/chat endpoint works, but it is completely open. Anyone can send a request without identifying themselves, which means the system has no way to know who is asking a question.

In this step, you will add JWT authentication so every request carries proof of who the user is. You will build a token utility, a security filter, and a login endpoint. By the end, authentication will work perfectly. But you will discover a catch: the AI still treats everyone identically.

In this step, get ready to:
  • Create a JWT utility for generating and validating tokens.
  • Build a login endpoint and security filter.
  • Configure Spring Security and test the authentication flow.
Create JwtUtil for token generation and validation

A JSON Web Token (JWT) is a compact, signed string that carries a user's identity. Your server creates one when the user logs in, and the client sends it back with every request to prove who they are.

The JwtUtil class handles both sides: building tokens at login time and reading them back on each request.

  • In IntelliJ, right-click the com.inventory.assistant package in the Project sidebar.
  • Select New then Package and name it security.
  • Right-click the new security package, select New then Java Class, and name it JwtUtil.
  • Replace the file contents with this code to set up the class with its signing key:
package com.inventory.assistant.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtUtil {

    // Read the Base64-encoded secret from application.properties
    @Value("${jwt.secret}")
    private String secret;

    // Token validity duration in milliseconds (24 hours)
    @Value("${jwt.expiration}")
    private long expiration;

    // Decode the Base64 secret into an HMAC-SHA signing key
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

What does this code do?

The @Value annotations pull the secret key and expiration time from your application.properties file. These properties were already added in Step 1.

The getSigningKey() method decodes the Base64 secret into a cryptographic key that JJWT uses to sign and verify tokens. Every token operation in this class flows through this key.

  • Inside JwtUtil, add the token generation method above the closing } of the class:
    // Build a JWT containing the user's identity and role
    public String generateToken(Long userId, String username, String role) {
        return Jwts.builder()
                .subject(username)
                .claim("userId", userId)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }

What does generateToken do?

This method packs three pieces of information into a signed token: the username (as the subject), the userId, and the role. The token is signed with your secret key so nobody can tamper with it. It expires after 24 hours.

  • Below the generateToken method, add the extraction and validation methods:
    // Extract individual claims from a validated token
    public String extractUsername(String token) {
        return extractClaims(token).getSubject();
    }

    public Long extractUserId(String token) {
        return extractClaims(token).get("userId", Long.class);
    }

    public String extractRole(String token) {
        return extractClaims(token).get("role", String.class);
    }

    // Check whether the token can be parsed without errors
    public boolean isTokenValid(String token) {
        try {
            extractClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private Claims extractClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

What do these methods do?

The three extract methods pull specific claims out of a token after verifying its signature. The isTokenValid() method returns true if the token parses successfully, or false if it is expired, tampered with, or malformed.

The private extractClaims() helper does the real work. It verifies the token's HMAC-SHA256 signature using your signing key. If the signature does not match, JJWT throws an exception.

  • Save JwtUtil.java.

✔️ Awesome, I've got everything!

Great. Make sure you have saved JwtUtil.java before continuing.

ⓧ I'd like to double check the full code

Scroll up and confirm your JwtUtil.java contains these sections in order:

  • Package declaration, all imports, and the @Component class header.
  • Two @Value fields (secret and expiration) and the getSigningKey() method.
  • The generateToken() method with userId, username, and role parameters.
  • Three extract methods (extractUsername, extractUserId, extractRole), isTokenValid(), and the private extractClaims() helper.
  • Restart the app by clicking the green Run button in IntelliJ (or stop and re-run).
  • Confirm the console shows Started InventoryAssistantApplication with no errors.

App won't start?

Check that all import statements match exactly. JJWT 0.13.0 uses io.jsonwebtoken.io.Decoders and io.jsonwebtoken.security.Keys. If IntelliJ highlights them in red, your pom.xml may not have the JJWT dependencies. Verify the three jjwt entries are present and re-import Maven.

Help me debug this startup error

Build the authentication filter and login endpoint

With the token utility ready, you need two more pieces. A filter that intercepts every request, reads the JWT from the Authorization header, and sets the user's identity in Spring's SecurityContext. And a login endpoint that checks credentials and hands back a signed token.

  • Right-click the security package, select New then Java Class, and name it JwtAuthenticationFilter.
  • Replace the file contents with this class setup:
package com.inventory.assistant.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }
}

What is OncePerRequestFilter?

Spring's OncePerRequestFilter guarantees that your filter logic runs exactly once per request. By extending it, your filter will inspect every incoming HTTP request for a JWT before the request reaches your controllers.

  • Inside JwtAuthenticationFilter, add the filter method above the closing } of the class:
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // Look for a Bearer token in the Authorization header
        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.isTokenValid(token)) {
                // Extract user details from the valid token
                String username = jwtUtil.extractUsername(token);
                String role = jwtUtil.extractRole(token);
                Long userId = jwtUtil.extractUserId(token);

                // Set the authenticated user in Spring's SecurityContext
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                username, null,
                                List.of(new SimpleGrantedAuthority("ROLE_" + role)));
                // Store userId so controllers can access it later
                authentication.setDetails(userId);

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

What does doFilterInternal do?

For every request, this method checks the Authorization header for a Bearer token. If it finds a valid one, it extracts the username, role, and userId, then places them into the SecurityContext.

Notice the role is prefixed with ROLE_. Spring Security expects this prefix for authority checks. The userId is stored in the authentication's details field so your controllers can retrieve it later.

  • Save JwtAuthenticationFilter.java.

Now create the login endpoint and the DTO it needs.

  • Right-click the dto package, select New then Java Class, choose Record, and name it LoginRequest.
  • Replace the file contents with this record:
package com.inventory.assistant.dto;

public record LoginRequest(String username, String password) {
}
  • Save LoginRequest.java.
  • Restart the app to confirm both new files compile cleanly.
  • Confirm the console shows Started InventoryAssistantApplication with no errors.

Seeing compilation errors?

Make sure the JwtAuthenticationFilter class extends OncePerRequestFilter and its constructor takes a JwtUtil parameter. If IntelliJ cannot resolve OncePerRequestFilter, verify that spring-boot-starter-web is in your pom.xml.

Help me fix this compilation error

Next, create the login controller that validates credentials and returns a JWT.

  • Right-click the controller package, select New then Java Class, and name it AuthController.
  • Replace the file contents with this class setup:
package com.inventory.assistant.controller;

import com.inventory.assistant.dto.LoginRequest;
import com.inventory.assistant.model.User;
import com.inventory.assistant.repository.UserRepository;
import com.inventory.assistant.security.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    public AuthController(UserRepository userRepository,
                          PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtUtil = jwtUtil;
    }
}
  • Inside AuthController, add the login method above the closing } of the class:
    // Validate credentials against the database and return a signed JWT
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        return userRepository.findByUsername(request.username())
                .filter(user -> passwordEncoder.matches(
                        request.password(), user.getPassword()))
                .map(user -> {
                    String token = jwtUtil.generateToken(
                            user.getId(), user.getUsername(),
                            user.getRole().name());
                    return ResponseEntity.ok(Map.of(
                            "token", token,
                            "username", user.getUsername(),
                            "role", user.getRole().name()));
                })
                .orElse(ResponseEntity.status(401)
                        .body(Map.of("error",
                                "Invalid username or password")));
    }

What does the login method do?

This method looks up the user by username, then uses BCrypt to compare the supplied password against the hashed password in the database. If the credentials match, it generates a JWT containing the user's ID, username, and role, then returns it in the response.

If the credentials are wrong, it returns a 401 status with an error message. The client stores the JWT and sends it with future requests.

  • Save AuthController.java.

✔️ Awesome, I've got everything!

Great. Make sure you have saved AuthController.java before continuing.

ⓧ I'd like to double check the full code

Scroll up and confirm your AuthController.java has these sections in order:

  • Package declaration, all imports, @RestController and @RequestMapping("/api/auth") annotations.
  • Three injected fields (userRepository, passwordEncoder, jwtUtil) and the constructor.
  • The login() method annotated with @PostMapping("/login").
Configure Spring Security and discover the gap

You have the token utility, the filter, and the login endpoint. Now you need a SecurityConfig that ties them together: disabling CSRF for a stateless API, requiring authentication for /api/chat, and registering your JWT filter in the security chain.

  • In the security package, create a new Java class named SecurityConfig. If a SecurityConfig file already exists from a previous step, replace its entire contents.
  • Paste this class setup:
package com.inventory.assistant.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }
}
  • Inside SecurityConfig, add the filter chain and password encoder beans above the closing } of the class:
    // Define which endpoints are public and which require authentication
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/", "/index.html", "/favicon.ico").permitAll()
                .anyRequest().authenticated()
            )
            // Run the JWT filter before Spring's default auth filter
            .addFilterBefore(jwtAuthenticationFilter,
                    UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // Provide a BCrypt encoder for hashing and verifying passwords
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

What does this configuration do?

CSRF protection is disabled because your API is stateless and uses tokens instead of cookies. SessionCreationPolicy.STATELESS tells Spring to never create HTTP sessions.

The authorizeHttpRequests section makes the login endpoint public while protecting everything else. The addFilterBefore call inserts your JWT filter so it runs before Spring's default authentication filter, giving your token a chance to set the SecurityContext.

  • Save SecurityConfig.java.

✔️ Awesome, I've got everything!

Great. Make sure you have saved SecurityConfig.java before continuing.

ⓧ I'd like to double check the full code

Scroll up and confirm your SecurityConfig.java has these sections in order:

  • Package declaration, all imports, @Configuration and @EnableWebSecurity annotations.
  • The jwtAuthenticationFilter field and constructor.
  • The filterChain() bean with CSRF disabled, stateless sessions, permitAll on /api/auth/**, and addFilterBefore.
  • The passwordEncoder() bean returning a BCryptPasswordEncoder.
  • Restart the app.
  • Confirm the console shows Started InventoryAssistantApplication with no errors.

Test that unauthenticated requests are now rejected by running this in your terminal:

curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"hello\"}"

You should see an error response instead of an AI answer. Security is working.

Still getting an AI response without a token?

Make sure you replaced the entire contents of your previous security configuration. If an old SecurityConfig class still permits all requests, the new rules will not apply. Delete any other security configuration class and keep only the one in the security package.

Help me figure out why my security config is not applied

Now update ChatService and ChatController so the controller passes the authenticated user's role and ID to the service.

  • Open ChatService.java and find this method signature:
    public String chat(String message) {
  • Replace it with this updated signature that accepts role and userId:
    public String chat(String message, String role, Long userId) {
  • Save ChatService.java.
  • Open ChatController.java and replace its entire contents with this updated version:
package com.inventory.assistant.controller;

import com.inventory.assistant.dto.ChatRequest;
import com.inventory.assistant.service.ChatService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatService chatService;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping("/chat")
    public Map<String, String> chat(@RequestBody ChatRequest request) {
        // Read the authenticated user's role and ID from the SecurityContext
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String role = auth.getAuthorities().iterator().next().getAuthority();
        Long userId = (Long) auth.getDetails();

        String response = chatService.chat(request.message(), role, userId);
        return Map.of("response", response);
    }
}

What changed in ChatController?

The controller now reads the authenticated user's role and userId from the SecurityContext that your JWT filter populated. It passes both values to ChatService.chat().

Right now, ChatService accepts these parameters but ignores them. You will see why that matters in a moment.

  • Save ChatController.java.
  • Restart the app and confirm it starts without errors.

Time to test the full authentication flow. First, log in as admin to get a JWT token.

  • Run this command in your terminal:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d "{\"username\":\"admin\",\"password\":\"admin123\"}"

You should see a JSON response containing a token, username, and role field.

  • Copy the token value from the response (the long string starting with eyJ).
  • Use it to call /api/chat as admin by running:
curl -X POST http://localhost:8080/api/chat -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d "{\"message\":\"What are total sales?\"}"

Replace YOUR_TOKEN with the actual token you copied. You should see a response from Gemini.

Now log in as customer1 and ask the same question. Before you do, think about this: do you expect admin and customer1 to receive different answers? Why or why not?

  • Run this command to log in as customer1:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d "{\"username\":\"customer1\",\"password\":\"cust123\"}"
  • Copy customer1's token and run the same chat request:
curl -X POST http://localhost:8080/api/chat -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d "{\"message\":\"What are total sales?\"}"

Both users get the same generic response from Gemini. Authentication works. The system knows who is asking. But the AI does not care.

Why does the AI treat everyone the same?

Your ChatService accepts the role and userId parameters but does not use them. It sends the user's message directly to Gemini with no system prompt. Gemini has no context about your users, your inventory, or your access rules.

The security layer and the AI layer are completely disconnected. Fixing that connection is the entire point of the next step.

Authentication is locked down. The system knows who is asking. In the next step, you will make the AI care about that identity by building role-based system prompts loaded with live inventory data.

Wire Role-Based Prompts and Build the Chat UI

In Step 4, you proved that authentication works. Admins and customers can both log in and get JWT tokens. But you also discovered a critical gap: Gemini gives the exact same answer to both roles because it has no context about who is asking.

This step closes that gap. You will refactor ChatService to query the database and build role-specific system prompts that inject live inventory data into every Gemini call. Then you will build a browser-based chat UI so you can switch users and see the AI adapt instantly.

In this step, get ready to:
  • Refactor ChatService with role-based system prompts.
  • Build a browser chat UI with login and messaging.
  • Verify that admin and customer get different AI responses.
Refactor ChatService with role-based prompts

ChatController already extracts the user's role and userId from the JWT and passes them to ChatService.chat(). The problem is ChatService currently ignores those values and sends every message to Gemini without any context.

  • Open ChatService.java at src/main/java/com/inventory/assistant/service/.
  • Select all the existing code and delete it.
  • Replace it with this code, which sets up the class with new repository dependencies:
package com.inventory.assistant.service;

import com.inventory.assistant.model.CustomerOrder;
import com.inventory.assistant.model.Product;
import com.inventory.assistant.repository.CustomerOrderRepository;
import com.inventory.assistant.repository.ProductRepository;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ChatService {

    private final ChatClient chatClient;
    private final ProductRepository productRepository;
    private final CustomerOrderRepository orderRepository;

    // Inject repositories so we can query live data for prompts
    public ChatService(ChatClient.Builder chatClientBuilder,
                       ProductRepository productRepository,
                       CustomerOrderRepository orderRepository) {
        this.chatClient = chatClientBuilder.build();
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }
  • Still in ChatService.java, add these two methods after the constructor:
    // Build a role-specific system prompt and send it with the message
    public String chat(String message, String role, Long userId) {
        String systemPrompt = buildSystemPrompt(role, userId);

        return chatClient.prompt()
                .system(systemPrompt)
                .user(message)
                .call()
                .content();
    }

    // Route to the correct prompt builder based on the user's role
    private String buildSystemPrompt(String role, Long userId) {
        if ("ROLE_ADMIN".equals(role)) {
            return buildAdminPrompt();
        } else {
            return buildCustomerPrompt(userId);
        }
    }
  • Add the admin prompt builder method after buildSystemPrompt():
    private String buildAdminPrompt() {
        // Load all products and orders for a system-wide view
        List<Product> products = productRepository.findAll();
        List<CustomerOrder> orders = orderRepository.findAll();

        StringBuilder sb = new StringBuilder();
        sb.append("You are an inventory management assistant for administrators. ");
        sb.append("You have access to the following system-wide inventory data.\n\n");
        sb.append("PRODUCTS:\n");
        for (Product p : products) {
            sb.append("- %s | Category: %s | Price: $%.2f | Stock: %d units".formatted(
                    p.getName(), p.getCategory(), p.getPrice(), p.getStockQuantity()));
            if (p.getStockQuantity() < 10) {
                sb.append(" [LOW STOCK]");
            }
            sb.append("\n");
        }
        sb.append("\nRECENT ORDERS:\n");
        for (CustomerOrder o : orders) {
            sb.append("- Order #%d: %s ordered %d x %s for $%.2f on %s\n".formatted(
                    o.getId(), o.getUser().getUsername(), o.getQuantity(),
                    o.getProduct().getName(), o.getTotalPrice(),
                    o.getOrderDate().toLocalDate()));
        }
        sb.append("\nAnswer questions about inventory levels, sales, and operations. ");
        sb.append("If a product has stock below 10, flag it as low stock and recommend restocking.");
        return sb.toString();
    }
  • Add the customer prompt builder method after buildAdminPrompt(), followed by the closing brace for the class:
    private String buildCustomerPrompt(Long userId) {
        // Load only this customer's orders for a restricted view
        List<CustomerOrder> orders = orderRepository.findByUserId(userId);

        StringBuilder sb = new StringBuilder();
        sb.append("You are a personal shopping assistant for a customer. ");
        sb.append("You can ONLY help with this customer's own orders.\n\n");
        sb.append("THIS CUSTOMER'S ORDERS:\n");
        if (orders.isEmpty()) {
            sb.append("- No orders found.\n");
        } else {
            for (CustomerOrder o : orders) {
                sb.append("- Order #%d: %d x %s for $%.2f on %s\n".formatted(
                        o.getId(), o.getQuantity(), o.getProduct().getName(),
                        o.getTotalPrice(), o.getOrderDate().toLocalDate()));
            }
        }
        sb.append("\nIMPORTANT RULES:\n");
        sb.append("- Do NOT provide information about other customers' orders\n");
        sb.append("- Do NOT provide system-wide sales data, total revenue, or inventory counts\n");
        sb.append("- If asked about overall inventory, other customers, or admin-level data, ");
        sb.append("politely explain you can only help with their own orders\n");
        sb.append("- You may answer general product questions but never reveal stock quantities");
        return sb.toString();
    }
}

What does this code do?

The refactored ChatService now does three things differently. First, chat() calls buildSystemPrompt() before every Gemini request, passing the user's role and ID from the JWT.

The buildAdminPrompt() method queries ALL products and ALL orders from the database. It formats them as structured text and flags any product with stock below 10 as [LOW STOCK]. Gemini sees the complete picture.

The buildCustomerPrompt() method queries only that customer's orders via findByUserId(). It also injects strict rules telling Gemini to refuse system-wide data, other customers' orders, and stock quantities.

I've got everything!

Great. Double check you have saved ChatService.java.

Let me double check the full code

The full file is built from the four sections above. Scroll up and confirm each section is present in this order:

  • Package, imports, class declaration, fields, and constructor.
  • The chat() and buildSystemPrompt() methods.
  • The buildAdminPrompt() method.
  • The buildCustomerPrompt() method and the class closing brace.
  • Save ChatService.java.
  • Restart the app by clicking the **Run** button in IntelliJ (or stop and re-run InventoryAssistantApplication).
  • Confirm the console prints Started InventoryAssistantApplication with no errors.

Before you test, predict: when you ask admin about low stock, will Gemini reference specific product names and quantities from the database, or give a generic answer like before?

  • Get an admin JWT token by running this command:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}'
  • Copy the token value from the JSON response (the long string starting with eyJ).
  • Ask the AI about low stock by running this command, replacing your-admin-token-here with the token you copied:
curl -X POST http://localhost:8080/api/chat -H "Authorization: Bearer your-admin-token-here" -H "Content-Type: application/json" -d '{"message":"What products are low on stock?"}'

Gemini now references specific products from your database. You should see it name the USB-C Hub and Mechanical Keyboard, both flagged as low stock with their exact quantities.

  • Now get a customer1 token by running:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"customer1","password":"cust123"}'
  • Copy customer1's token, then ask the same question by running:
curl -X POST http://localhost:8080/api/chat -H "Authorization: Bearer your-customer-token-here" -H "Content-Type: application/json" -d '{"message":"What products are low on stock?"}'

Gemini now refuses the question. It explains that it can only help with your own orders. Same model, same endpoint, completely different behavior.

Seeing the same generic response for both?

  • Make sure you saved ChatService.java and restarted the app after editing. The create-drop setting means the database resets on each restart, so you need fresh tokens too.
  • Confirm your buildSystemPrompt() method checks for "ROLE_ADMIN" (with the ROLE_ prefix). The JWT filter prepends this prefix when setting the authority.
  • Check the IntelliJ console for any SQL errors. If the repositories are not injected correctly, the prompt builders will not have data to work with.

help me debug why my ChatService is not returning role-specific responses

Create the chat UI

The curl tests prove role-based prompts work. Now you need a front end so you can switch users and chat without copying tokens manually. SecurityConfig already permits serving / and /index.html, so you just need to create the file.

  • In IntelliJ, right-click the src/main/resources/static folder and create a new file called index.html.
  • Paste this code, which sets up the HTML head and all the CSS styles:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Inventory Assistant</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
        .container { width: 100%; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: hidden; }
        .header { background: #1a73e8; color: white; padding: 16px 20px; font-size: 18px; font-weight: 600; }
        .header span { font-size: 13px; font-weight: 400; opacity: 0.85; margin-left: 8px; }
        .login-section { padding: 30px 20px; }
        .login-section h2 { margin-bottom: 16px; font-size: 16px; color: #333; }
        .login-section input { width: 100%; padding: 10px 12px; margin-bottom: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
        .login-section button { width: 100%; padding: 10px; background: #1a73e8; color: white; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
        .login-section button:hover { background: #1557b0; }
        .login-section .error { color: #d93025; font-size: 13px; margin-top: 8px; }
        .chat-section { display: none; height: 500px; display: flex; flex-direction: column; }
        .chat-section.active { display: flex; }
        .messages { flex: 1; overflow-y: auto; padding: 16px; }
        .message { margin-bottom: 12px; padding: 10px 14px; border-radius: 8px; max-width: 85%; font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
        .message.user { background: #1a73e8; color: white; margin-left: auto; }
        .message.bot { background: #f0f0f0; color: #333; }
        .input-area { display: flex; padding: 12px; border-top: 1px solid #eee; gap: 8px; }
        .input-area input { flex: 1; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
        .input-area button { padding: 10px 16px; background: #1a73e8; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
        .logout-btn { background: none; border: none; color: white; cursor: pointer; float: right; font-size: 13px; text-decoration: underline; }
    </style>
</head>
  • Continue in index.html. Add the body HTML with the login form, chat layout, and the opening of the script tag:
<body>
    <div class="container">
        <div class="header">
            Inventory Assistant
            <span id="userInfo"></span>
            <button class="logout-btn" id="logoutBtn" style="display:none;" onclick="logout()">Logout</button>
        </div>

        <div class="login-section" id="loginSection">
            <h2>Sign in to chat with your AI assistant</h2>
            <input type="text" id="username" placeholder="Username" />
            <input type="password" id="password" placeholder="Password" />
            <button onclick="login()">Sign In</button>
            <p class="error" id="loginError"></p>
            <p style="margin-top:12px; font-size:12px; color:#888;">
                Try: admin / admin123 or customer1 / cust123
            </p>
        </div>

        <div class="chat-section" id="chatSection">
            <div class="messages" id="messages"></div>
            <div class="input-area">
                <input type="text" id="chatInput" placeholder="Ask about inventory..."
                       onkeydown="if(event.key==='Enter') sendMessage()" />
                <button onclick="sendMessage()">Send</button>
            </div>
        </div>
    </div>

    <script>
        let token = null;
  • Save index.html and open http://localhost:8080 in your browser.

You should see a white card with a blue header reading "Inventory Assistant" and a login form with Username and Password fields. The form will not work yet because the JavaScript is not added.

  • Back in index.html, add the login() function after let token = null;:
        // POST credentials to /api/auth/login and store the JWT token
        async function login() {
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            const errorEl = document.getElementById('loginError');
            errorEl.textContent = '';
            try {
                const res = await fetch('/api/auth/login', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ username, password })
                });
                const data = await res.json();
                if (res.ok) {
                    token = data.token;
                    document.getElementById('loginSection').style.display = 'none';
                    document.getElementById('chatSection').classList.add('active');
                    document.getElementById('userInfo').textContent = '(' + data.username + ' - ' + data.role + ')';
                    document.getElementById('logoutBtn').style.display = 'inline';
                    document.getElementById('messages').innerHTML = '';
                    addMessage('bot', 'Hello! I am your inventory assistant. How can I help you today?');
                } else {
                    errorEl.textContent = data.error || 'Login failed';
                }
            } catch (e) {
                errorEl.textContent = 'Connection error';
            }
        }
  • Add the logout() and sendMessage() functions after login():
        // Reset UI to login state
        function logout() {
            token = null;
            document.getElementById('loginSection').style.display = 'block';
            document.getElementById('chatSection').classList.remove('active');
            document.getElementById('logoutBtn').style.display = 'none';
            document.getElementById('userInfo').textContent = '';
            document.getElementById('username').value = '';
            document.getElementById('password').value = '';
        }

        // Send message to /api/chat with JWT in the Authorization header
        async function sendMessage() {
            const input = document.getElementById('chatInput');
            const message = input.value.trim();
            if (!message) return;
            addMessage('user', message);
            input.value = '';
            try {
                const res = await fetch('/api/chat', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': 'Bearer ' + token
                    },
                    body: JSON.stringify({ message })
                });
                const data = await res.json();
                addMessage('bot', data.response);
            } catch (e) {
                addMessage('bot', 'Error: Could not reach the server.');
            }
        }
  • Add the addMessage() helper function and the closing tags after sendMessage():
        // Append a message bubble to the chat area
        function addMessage(type, text) {
            const messagesDiv = document.getElementById('messages');
            const div = document.createElement('div');
            div.className = 'message ' + (type === 'user' ? 'user' : 'bot');
            div.textContent = text;
            messagesDiv.appendChild(div);
            messagesDiv.scrollTop = messagesDiv.scrollHeight;
        }
    </script>
</body>
</html>

What does the JavaScript do?

The login() function POSTs the username and password to /api/auth/login. On success, it stores the JWT token in a variable and switches from the login form to the chat view. The header shows which user and role are active.

The sendMessage() function POSTs the chat message to /api/chat with the JWT in the Authorization header. The server uses that token to identify the user's role and build the correct system prompt.

I've got everything!

Great. Double check you have saved index.html.

Let me double check the full code

The full file is built from the five sections above. Scroll up and confirm each section is present in this order:

  • HTML head with all CSS styles (from <!DOCTYPE html> through </head>).
  • Body HTML with login form, chat layout, and let token = null;.
  • The login() function.
  • The logout() and sendMessage() functions.
  • The addMessage() function with </script></body></html> closing tags.
  • Save index.html.
  • Restart the app in IntelliJ.
Test role-based behavior in the browser

This is the moment the whole project comes together. You will log in as two different users and watch Gemini change its behavior based on who is asking.

  • Open http://localhost:8080 in your browser.
  • Enter admin as the username and admin123 as the password.
  • Click **Sign In**.

The header should now show "(admin - ADMIN)" with a Logout link. You see a welcome message from the assistant.

  • Type What products are low on stock? in the chat input and click **Send**.

Before you read the response, predict: will Gemini name specific products with exact stock quantities?

Gemini should list the USB-C Hub (8 units) and Mechanical Keyboard (3 units) as low stock, with a recommendation to restock. It has full visibility into the inventory because buildAdminPrompt() injected every product and order into the system prompt.

  • Click **Logout** in the top right corner.
  • Log in as customer1 with password cust123.
  • Ask the same question: What products are low on stock?.

Gemini should politely decline. It explains that it can only help with your own orders and cannot share system-wide inventory data. Same model, same question, completely different answer.

Chat UI not loading or login failing?

  • Make sure index.html is saved inside src/main/resources/static/. Spring Boot serves static files from this directory automatically.
  • If you see a blank page, open your browser's developer console (F12) and check for JavaScript errors. A missing closing brace or misplaced function can break the entire script.
  • If login returns an error, confirm the app restarted successfully. The create-drop setting recreates the database on every restart, so the seeded users are always fresh.

help me debug my Inventory Assistant chat UI

Your inventory assistant now gives each user a tailored experience. The same AI model serves both admin insights and customer-specific answers, all controlled by the system prompt. Next up, you can push this further with an optional Secret Mission.

Secret mission

Add Conversation Memory

Right now every chat message is completely independent. Ask "What products are low stock?" then follow up with "Tell me more about the first one" and Gemini has no idea what you mean. Can you add per-user conversation memory so follow-up questions actually work?

Clean Up Your Resources

Clean Up Your Resources

This project uses a local MySQL database and the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The inventory_assistant MySQL database with sample users, products, and orders.
  • A Gemini API key linked to your Google account on the free tier.
  • The Spring Boot project files in your assistant folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment more with role-based prompts.

  • The inventory_assistant MySQL database persists locally between sessions at no cost.
  • The Gemini API free tier has rate limits but no charges. Content sent on the free tier is used to improve Google products.
  • The app uses create-drop for JPA, so tables are recreated with fresh sample data each time you start the app.

Pause - I'll come back to this later

Stop the running application to free up memory. Your database and API key remain configured for when you come back.

  • In IntelliJ, click the red Stop button in the toolbar to shut down the Spring Boot app.
  • Your MySQL database and Gemini API key stay in place. Next time, just click Run in IntelliJ to start the app again.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Spring Boot app in IntelliJ if it is still running.
  • Drop the inventory_assistant database. Open a terminal and connect to MySQL by running:
mysql -u root -p
  • Enter your MySQL root password when prompted, then run this SQL command:
DROP DATABASE inventory_assistant;
  • Type exit to leave the MySQL prompt.
  • Revoke your Gemini API key if you no longer need it. Go to https://aistudio.google.com/apikey, find your key, and click the trash icon to delete it.
  • Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf assistant

On Windows, run:

cd ~\Desktop
rmdir /s /q assistant

Well Done!

Well Done!

You just built a secured, role-aware AI assistant powered by Spring Boot and Google Gemini. The same AI model, the same endpoint, and completely different answers depending on who is asking.

You've learned how to:

  • Built a Spring AI application that connects to Gemini using the Google GenAI starter and the ChatClient API to send messages and receive AI-generated responses.
  • Implemented JWT authentication from scratch using JJWT, including token generation with role claims, a security filter, and protected endpoints that identify every caller.
  • Engineered role-based system prompts that dynamically inject live database context into every Gemini call, giving admins full inventory visibility while restricting customers to their own order data.
  • Secret Mission: Added per-user conversation memory so the chatbot maintains context across multiple messages in a session.

Ready to quiz yourself?