Connect a Security Console to PostgreSQL
Connect a React security console to a Spring Boot API and PostgreSQL.
Introduction
30 Second Summary
A security console can look convincing while every acknowledgement lives only on the current screen. One refresh can erase the operator's work.
In this project, you will connect your existing security incident console to a Spring Boot REST API. PostgreSQL will store each incident so acknowledgements survive browser refreshes.
What You'll Build
A browser refresh after acknowledging INC-104 still shows Acknowledged in the live incident report.
By the end of this project, you'll have:
- A live incident report that loads INC-101 through INC-106 from a Java API backed by PostgreSQL.
- Versioned Flyway migrations that create the database schema in a repeatable order. The migrations also seed the six incident records.
- A persistent acknowledgement workflow where the React console reads incidents through a narrowly scoped CORS policy. The same workflow sends acknowledgement updates to the API. Your changes remain after a refresh.
- Secret Mission: Add a database-backed timestamp to every new acknowledgement through a forward-only migration.
Are there any prerequisites?
You need the completed Build a Security Incident Console project plus basic Java knowledge. The guide covers backend tool setup on Windows.
Before We Start
This checkpoint defines the change you are about to make before the backend work begins. You are replacing the temporary local incident array with a PostgreSQL-backed API while preserving the console's filters, views, details panel, and operator acknowledgement workflow.
Prepare Java and PostgreSQL
The incident console still keeps its records in browser memory. A persistent backend needs durable storage.
Spring Boot provides the API through Java 21. PostgreSQL 18 stores the incident records.
Visual Studio Code extension packs support backend development. pgAdmin confirms that Windows can reach the local database server.
In this step, get ready to:
- Verify that Windows uses Java 21.
- Equip Visual Studio Code for Spring Boot development.
- Prepare a local PostgreSQL 18 server for pgAdmin access.
Verify Java 21
The Java Development Kit provides the runtime Spring Boot needs. It also provides the compiler used to build the backend.
- Press the Windows key to open Windows search.
- Type Windows PowerShell.
- Press Enter to open Windows PowerShell.
- Check the active Java version by running this command:
java -version
What does this command check?
- The java command starts the Java runtime available through your Windows PATH.
- The -version option prints the runtime version currently selected by Windows.
Choose the result below that matches the output in PowerShell.
✔️ I see Java 21
Your Java runtime is ready. Windows can now provide the required version to Spring Boot.
ⓧ I see an older Java version
- Visit the official Eclipse Temurin Windows installation guide.
- Download the Eclipse Temurin 21 JDK as an x64 Windows .msi package.
- Run the downloaded installer.
- Keep the installer option that adds Java to PATH enabled.
- Complete the installation.
Why reopen PowerShell?
The current PowerShell process keeps the environment settings it received when it opened. A new process receives the updated Java path from the installer.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell.
- Press Enter to open a new PowerShell window.
- Rerun the Java version check shown above.
You should now see Java 21 in the version output.
ⓧ Command not found
- Visit the official Eclipse Temurin Windows installation guide.
- Download the Eclipse Temurin 21 JDK as an x64 Windows .msi package.
- Run the downloaded installer.
- Keep the installer option that adds Java to PATH enabled.
- Complete the installation.
Why reopen PowerShell?
The installer updates the environment used by new applications. A new PowerShell process can find the Java command through the updated path.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell.
- Press Enter to open a new PowerShell window.
- Rerun the Java version check shown above.
You should now see Java 21 in the version output.
Still seeing the wrong Java version?
Check that you opened a new PowerShell window after installation. Rerun the Temurin installer if the option that adds Java to PATH was disabled.
Help me fix the Java version selected by Windows PowerShell.
Install the Java and Spring extensions
The Java extension pack adds language support for Java projects. The Spring extension pack adds tools for creating Spring Boot projects.
- Press the Windows key to open Windows search.
- Type Visual Studio Code.
- Press Enter to open Visual Studio Code.
- Click the Extensions icon in the Activity Bar.
- Enter Extension Pack for Java in the Extensions search field.
- Select the matching extension result.
- Click Install on the extension page.
What does the Java pack add?
This pack gives Visual Studio Code Java language support. It also adds project management and debugging features for the backend you create next.
- Replace the Extensions search value with Spring Boot Extension Pack.
- Select the matching extension result.
- Click Install on the extension page.
- Confirm that the extension page shows the pack as installed.
Both extension packs are now available. Visual Studio Code is ready to generate and work with the Java backend.
Extensions failing to install?
Confirm that Visual Studio Code can access the internet. Restart the app if an extension remains stuck during installation.
Help me diagnose a Visual Studio Code extension installation problem. Compare your setup with the official Spring Boot guide for Visual Studio Code.
Install PostgreSQL 18 and connect pgAdmin
PostgreSQL keeps the incident records after the browser refreshes. pgAdmin provides a graphical connection to the local server.
The EDB Windows installer includes the PostgreSQL server plus pgAdmin. First, identify whether either tool is already present.
- Press the Windows key to open Windows search.
- Type pgAdmin 4.
- Choose the tab below that matches your Windows setup.
✔️ PostgreSQL 18 and pgAdmin 4 are installed
Your pinned database tools are already present. The connection instructions below confirm that the local server is running.
ⓧ I have an older PostgreSQL version
Major PostgreSQL upgrades can require a data migration. The supported methods depend on whether you need to keep the databases in the older installation.
- Open the official PostgreSQL 18 upgrade guide.
- Back up any existing databases you need to keep.
- Follow one documented major-upgrade method until PostgreSQL 18 runs locally on port 5432.
- Install pgAdmin 4 from the EDB installer if it is missing.
ⓧ PostgreSQL is not installed
- Visit the official PostgreSQL Windows installer page.
- Download the EDB installer for PostgreSQL 18.6.
- Run the downloaded installer.
- Keep PostgreSQL server selected in the component list.
- Keep pgAdmin selected in the component list.
What does the installer add?
The PostgreSQL server runs the database service on Windows. pgAdmin gives you a graphical interface for connecting to that service.
The password protects only your local PostgreSQL account. You keep full control because the server runs on your PC.
- Create a password for the local postgres account when prompted.
- Store the password in your password manager.
- Keep the default port set to 5432.
- Complete the installation.
Connecting pgAdmin proves that the PostgreSQL service is running. The installer password authorizes that local connection.
- Press the Windows key to open Windows search.
- Type pgAdmin 4.
- Press Enter to open pgAdmin.
- Expand the local PostgreSQL 18 server in the server tree.
- Enter the password you created during installation when prompted.
- Expand the connected server.
You should see the Databases node beneath the connected server. This proves that pgAdmin can reach your local PostgreSQL service.
Cannot connect to PostgreSQL?
Check that you selected the local PostgreSQL 18 server. Re-enter the password created by the EDB installer.
Help me diagnose my local pgAdmin connection. Compare your installation with the official PostgreSQL Windows installer page.
Before the final check, predict whether PowerShell now points to Java 21.
- Switch back to the Windows PowerShell window from earlier.
- Confirm the Java runtime by running this command:
java -version
What should you see?
The version output should report Java 21. This confirms that Windows can provide the required runtime to the backend.
- Switch back to pgAdmin from earlier.
- Expand the connected PostgreSQL server in the server tree.
You should still see the Databases node. Your Java runtime and local database service are both ready.
Your Windows backend foundation is ready. Next up, you will create the project database and scaffold the Spring Boot application that connects to it.
Create the Spring Boot Project
Your Windows development tools are ready. However, PostgreSQL still needs a database for the incident records.
In this step, you will use Spring Initializr to create a Spring Boot project managed by Maven. You will connect that project to a new local database.
In this step, get ready to:
- Create the incident_console database in pgAdmin.
- Generate the incident-api Maven project with Java 21.
- Configure the database connection and start the backend.
Create the incident database
A PostgreSQL server can hold several separate databases. You will give this backend its own database named incident_console through pgAdmin.
- Return to pgAdmin from the previous step.
- Expand your connected local PostgreSQL server in the left tree.
- Right-click the Databases node.
- Select Create Database.
- Enter incident_console in the Database field.
- Select Save.
You should see incident_console under the Databases node. This confirms that PostgreSQL has a destination for the backend connection.
Database missing from the tree?
Refresh the Databases node if the new database is not listed. Confirm that the local server still shows as connected.
If pgAdmin rejects the action, help me troubleshoot database creation in pgAdmin.
Generate and configure the Maven project
Spring Initializr creates the Maven wrapper and the standard Java source folders. The generated structure gives Visual Studio Code a project it can import immediately.
- Switch back to Visual Studio Code from the previous step.
- Press Ctrl+Shift+P to open the Command Palette.
- Type Spring Initializr into the search field.
- Select Spring Initializr: Create a Maven Project.
- Select Spring Boot version 4.1.2.
- Select Java as the language.
- Enter com.nextwork for the group.
- Enter incident-api for the artifact.
How does the package name get created?
Spring Initializr combines com.nextwork with the Java-safe form of incident-api. This produces the package com.nextwork.incidentapi.
- Select Jar for the packaging type.
- Select Java version 21.
- Search for Spring Web in the dependency picker.
- Select Spring Web.
- Search for Spring Data JPA in the dependency picker.
- Select Spring Data JPA.
- Search for Flyway Migration in the dependency picker.
- Select Flyway Migration.
Complete the dependency selection
The project also needs PostgreSQL Driver. This final dependency gives Java the runtime connection to PostgreSQL.
- Search for PostgreSQL Driver in the dependency picker.
- Select PostgreSQL Driver.
- Press Enter to finish the dependency selection.
- Select your Desktop in the folder picker.
- Select Generate into this folder.
- Select Open when Visual Studio Code offers to open the generated project.
You should see pom.xml, mvnw.cmd, and the src folder in the Explorer sidebar. The generated project now has the structure required by the backend.
Why replace the generated Maven settings?
The generated file provides a starting point. The next edits pin the project to the exact Spring Boot parent and add the PostgreSQL-specific Flyway module required by this backend.
- Select pom.xml in the Explorer sidebar.
- Select the section from <modelVersion> through </properties>.
- Replace the selected section with this project identity and Java configuration:
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>
<groupId>com.nextwork</groupId>
<artifactId>incident-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>incident-api</name>
<description>PostgreSQL backend for the Security Incident Console</description>
<properties>
<java.version>21</java.version>
</properties>
What does this Maven section do?
- The parent selects Spring Boot 4.1.2 and its managed dependency versions.
- The project coordinates identify the backend as com.nextwork:incident-api.
- The Java property tells Maven to compile the project for Java 21.
- Press Ctrl+S to save pom.xml.
- Check the Problems panel for XML syntax problems in pom.xml.
The file should have no XML syntax problems after this replacement.
Seeing an XML syntax problem?
Confirm that the opening <project> element remains above the replacement. Confirm that the closing </project> element remains at the bottom.
If the highlighted tags still do not match, help me repair the project metadata in pom.xml.
- Select the complete <dependencies> section in pom.xml.
- Replace the selected section with these backend dependencies:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
What do these dependencies provide?
- Spring Web MVC provides the HTTP layer for the incident API.
- Spring Data JPA connects Java entities to database records.
- Flyway manages versioned database changes through migration files.
- The PostgreSQL modules provide migration support and the JDBC runtime driver.
- Press Ctrl+S to save pom.xml.
- Watch the Maven import status until dependency resolution finishes.
Expect the first Maven refresh to take a few minutes while it downloads the project libraries. The Maven status should finish without a dependency resolution problem.
Maven cannot resolve a dependency?
Check that every group ID and artifact ID matches the block above. Confirm that your computer still has an internet connection for the first dependency download.
If Maven still cannot finish, help me diagnose the Maven dependency failure.
- Select the complete <build> section in pom.xml.
- Replace the selected section with this Spring Boot plugin configuration:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
What does this plugin do?
The Spring Boot Maven plugin provides the spring-boot:run goal. The Maven wrapper uses that goal to launch the application from the project folder.
- Press Ctrl+S to save pom.xml.
- Check the Problems panel for XML syntax problems.
The final Maven descriptor should save without an XML syntax problem.
Maven plugin section highlighted?
Confirm that the <build> section sits after </dependencies> and before </project>.
If the nesting still looks wrong, help me fix the Maven plugin section.
Connect and start the backend
The backend needs a JDBC address and a database user before it can connect. The password stays outside the project through a session-only environment variable.
- Select src/main/resources/application.properties in the Explorer sidebar.
- Press Ctrl+A to select the generated contents.
- Replace the selected contents with this database configuration:
spring.application.name=incident-api
spring.datasource.url=jdbc:postgresql://localhost:5432/incident_console
spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
How does the connection work?
- The JDBC URL points to the local PostgreSQL server on port 5432 and selects incident_console.
- The username selects the local postgres database user.
- The password property reads DB_PASSWORD from the running PowerShell process.
- The validate mode lets Hibernate verify future entity mappings without creating the schema itself.
- Press Ctrl+S to save application.properties.
- Check the Problems panel for property syntax problems.
The file should contain five saved properties with no syntax problem.
Database properties highlighted?
Check each property for spaces around the equals sign. Confirm that the password placeholder remains exactly ${DB_PASSWORD}.
If the editor still flags the configuration, help me check application.properties.
Spring Initializr created the application entry point. Replacing its contents ensures that its package and class name match the project.
- Select src/main/java/com/nextwork/incidentapi/IncidentApiApplication.java in the Explorer sidebar.
- Press Ctrl+A to select the generated contents.
- Replace the selected contents with this application entry point:
package com.nextwork.incidentapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IncidentApiApplication {
public static void main(String[] args) {
SpringApplication.run(IncidentApiApplication.class, args);
}
}
What does this class do?
- The package places the class under com.nextwork.incidentapi.
- The @SpringBootApplication annotation enables Spring Boot configuration and component discovery.
- The main() method starts the application through SpringApplication.run().
- Press Ctrl+S to save IncidentApiApplication.java.
- Check the Problems panel for Java compilation problems.
The entry point should have no Java compilation problem after Maven finishes importing the dependencies.
Spring imports still unresolved?
Wait for the Maven import to finish. Confirm that the package declaration matches the folders under src/main/java.
If the imports remain unresolved, help me restore the Spring Boot entry point.
Compare the three project files before starting the application.
✔️ Awesome, I've got everything!
Your Maven descriptor, database properties, and application class are saved. Continue to the startup check.
ⓧ I'd like to double check the full code
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>
<groupId>com.nextwork</groupId>
<artifactId>incident-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>incident-api</name>
<description>PostgreSQL backend for the Security Incident Console</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Check the Maven descriptor
Compare the parent, project metadata, dependencies, and build plugin with your saved pom.xml.
spring.application.name=incident-api
spring.datasource.url=jdbc:postgresql://localhost:5432/incident_console
spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
Check the database properties
Compare all five lines with your saved application.properties file.
package com.nextwork.incidentapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IncidentApiApplication {
public static void main(String[] args) {
SpringApplication.run(IncidentApiApplication.class, args);
}
}
Check the application entry point
Compare the package, imports, annotation, class name, and main() method with your saved Java file.
Your database password stays in the current PowerShell session. This keeps the credential out of the project files and the final screenshot.
- Select Terminal in the Visual Studio Code menu bar.
- Select New Terminal.
- Set the session-only database password by running this command with your actual local PostgreSQL password:
$Env:DB_PASSWORD = "your-postgres-password-here"
What does this command do?
The command stores the password in the current PowerShell process. The Maven wrapper inherits it when the backend starts.
- Replace your-postgres-password-here with the password you chose during PostgreSQL installation.
- Press Enter to set the environment variable.
Before you start the backend, do you expect the application to connect to the new empty database?
The first launch can take a few minutes while Maven completes any remaining downloads. The terminal stays occupied because the backend keeps running.
- Start the Spring Boot application by running this command:
.\mvnw.cmd spring-boot:run
What should you see?
The Maven wrapper compiles the project and starts the embedded server. The final log lines should include Started IncidentApiApplication with no database connection error.
The process keeps running in this terminal so the backend remains available for the next step.
Backend did not start?
Confirm that incident_console exists in pgAdmin. Confirm that the current terminal has the correct database password set.
If Maven reports a connection or build problem, help me diagnose the Spring Boot startup failure.
That is the backend foundation working. Your Java process can now reach the local PostgreSQL database.
- Scroll the terminal until the password-setting command is outside the visible area.
Your Spring Boot project is connected and ready. Next, you will give the empty database its incident records and expose them through the API.
Expose Incidents as JSON
The backend now connects to PostgreSQL. However, the database has no durable incident records for the console to load.
In this step, Flyway creates the incident schema. JPA maps each row to Java. A REST controller then exposes all six records as JSON.
In this step, get ready to:
- Create the constrained incident table through versioned migrations.
- Map the incident records with a JPA entity and repository.
- Serve six sorted incidents from the backend endpoint.
Create and seed the incident table
A versioned migration records each database change as a numbered SQL file. Flyway applies pending files in order when Spring Boot starts.
- In the Visual Studio Code Explorer sidebar, expand src/main/resources.
- Create a db/migration folder under src/main/resources with the New Folder control.
- Select the new db/migration folder.
- Create V1__create_incidents.sql with the New File control.
- Define the incident table by pasting this code into V1__create_incidents.sql:
CREATE TABLE incidents (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(120) NOT NULL,
type VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL,
location VARCHAR(120) NOT NULL,
incident_time VARCHAR(5) NOT NULL,
description VARCHAR(500) NOT NULL,
CONSTRAINT incidents_type_check
CHECK (type IN ('Alarm', 'Door activity', 'Camera event')),
CONSTRAINT incidents_status_check
CHECK (status IN ('Active', 'Acknowledged', 'Resolved'))
);
What does this migration do?
- The primary key gives every incident a unique ID.
- The required columns prevent incomplete records from entering the report.
- The check constraints limit incident types and statuses to values supported by the existing frontend.
- Save V1__create_incidents.sql.
The Explorer should list V1__create_incidents.sql inside src/main/resources/db/migration.
Migration file missing?
Confirm that the file sits inside src/main/resources/db/migration. Check that its name contains two underscores after V1.
If the file still does not appear, help me check my Flyway migration path.
- Select the db/migration folder in the Explorer sidebar.
- Create V2__seed_incidents.sql with the New File control.
- Seed the six frontend-compatible records by pasting this code into V2__seed_incidents.sql:
INSERT INTO incidents (id, title, type, status, location, incident_time, description) VALUES
('INC-101', 'Forced door detected', 'Door activity', 'Active', 'North entrance', '09:42', 'The north entrance reported a forced-open state.'),
('INC-102', 'Motion after hours', 'Camera event', 'Acknowledged', 'Warehouse aisle 4', '09:36', 'Motion was detected outside the configured operating window.'),
('INC-103', 'Badge access denied', 'Door activity', 'Resolved', 'Research lab', '09:28', 'A credential was denied at the research lab reader.'),
('INC-104', 'Perimeter alarm triggered', 'Alarm', 'Active', 'West fence', '09:17', 'A perimeter sensor entered its active alarm state.'),
('INC-105', 'Camera signal restored', 'Camera event', 'Resolved', 'Parking level 2', '09:03', 'The camera resumed sending a healthy signal.'),
('INC-106', 'Emergency exit opened', 'Alarm', 'Active', 'South stairwell', '08:55', 'The emergency exit opened while the area was secured.');
What does this migration do?
This migration inserts INC-101 through INC-106 with the exact fields expected by the frontend. Their existing types and statuses keep the filters compatible.
- Save V2__seed_incidents.sql.
- Switch back to the PowerShell terminal from the previous step.
- Start the backend so Flyway applies both migrations by running:
.\mvnw.cmd spring-boot:run
What happens during startup?
Flyway finds both pending migration files. It creates the schema history table before applying the table definition and seed data in version order.
- Return to the connected PostgreSQL server in pgAdmin.
- Expand the incident_console database to its tables.
- Refresh the tables list.
- Use the data viewer for the incidents table.
You should see incidents beside flyway_schema_history. The incident table should contain six rows from INC-101 through INC-106.
Tables or rows missing?
Confirm that both migration files use the exact names shown above. Verify that the files are under src/main/resources/db/migration.
If startup reports a migration problem, help me diagnose my Flyway migrations.
Map incidents with JPA
A JPA entity gives each database column a Java field. Spring Data JPA then supplies repository operations for reading those entities.
- Return to the PowerShell terminal running the backend.
- Press Ctrl+C to stop the backend.
- In the Explorer sidebar, select src/main/java/com/nextwork/incidentapi.
- Create Incident.java with the New File control.
- Create the entity shell by pasting this code into Incident.java:
package com.nextwork.incidentapi;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "incidents")
public class Incident {
}
What does this code do?
- The package keeps the entity inside the application's existing component area.
- The entity annotation makes the class available to JPA.
- The table annotation connects the class to the incidents table.
- Save Incident.java.
- Open the Problems panel in Visual Studio Code.
You should see no Java syntax errors for the entity shell.
Entity imports unresolved?
Confirm that pom.xml still contains the Spring Data JPA dependency from the previous step. Save pom.xml so Visual Studio Code can refresh the Java project.
If the imports remain unresolved, help me restore the JPA imports.
- In Incident.java, place the cursor above the final closing brace.
- Add the mapped fields and protected constructor by pasting:
@Id
@Column(length = 20)
private String id;
@Column(nullable = false, length = 120)
private String title;
@Column(nullable = false, length = 40)
private String type;
@Column(nullable = false, length = 20)
private String status;
@Column(nullable = false, length = 120)
private String location;
@Column(name = "incident_time", nullable = false, length = 5)
private String time;
@Column(nullable = false, length = 500)
private String description;
protected Incident() {
}
How do these fields map?
- The ID annotation identifies the primary key field.
- The column settings mirror the database lengths and required values.
- The time field maps to incident_time because the Java and database names differ.
- The protected constructor gives JPA the no-argument constructor it needs.
- Save Incident.java.
- Return to the Problems panel.
You should still see no Java syntax errors for Incident.java.
Fields outside the class?
Make sure the new fields sit between the opening class brace and the final closing brace. Keep the protected constructor below the fields.
If Visual Studio Code still flags the structure, help me place the entity fields correctly.
- In Incident.java, place the cursor below the protected constructor.
- Add the first group of accessors by pasting:
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public String getType() {
return type;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
Why add these accessors?
The getters expose the fields when Spring converts an incident to JSON. The status setter prepares the entity for the persistent acknowledgement workflow in a later step.
- Save Incident.java.
- Return to the Problems panel.
You should see no method or brace errors for the first accessor group.
Methods nested inside the constructor?
Confirm that the protected constructor closes before getId() begins. Every method must sit directly inside the Incident class.
If the braces remain unclear, help me repair the Incident class structure.
- In Incident.java, place the cursor below setStatus().
- Add the remaining accessors by pasting:
public String getLocation() {
return location;
}
public String getTime() {
return time;
}
public String getDescription() {
return description;
}
What completes the entity?
These getters complete the frontend data contract. Each incident can now expose its location, display time, and description in the JSON response.
- Save Incident.java.
- Return to the Problems panel.
You should see no Java errors for the completed entity.
Getter names flagged?
Compare each method name with its field. Check that every method returns the matching field inside its braces.
If an error remains, help me check the Incident getters.
- In the Explorer sidebar, select src/main/java/com/nextwork/incidentapi.
- Create IncidentRepository.java with the New File control.
- Define the incident repository by pasting this code into IncidentRepository.java:
package com.nextwork.incidentapi;
import org.springframework.data.jpa.repository.JpaRepository;
public interface IncidentRepository extends JpaRepository<Incident, String> {
}
What does the repository provide?
The repository connects the Incident entity to standard persistence operations. The String type matches the incident ID used as the primary key.
- Save IncidentRepository.java.
- Return to the Problems panel.
You should see no Java errors for IncidentRepository.java.
Repository type unresolved?
Confirm that Incident.java and IncidentRepository.java both use the com.nextwork.incidentapi package.
If the repository still cannot find the entity, help me align the Java package names.
Serve the incident collection
The repository can read database rows, but the browser needs an HTTP representation. A controller turns the repository result into a sorted JSON response.
- In the Explorer sidebar, select src/main/java/com/nextwork/incidentapi.
- Create IncidentController.java with the New File control.
- Define the read endpoint by pasting this code into IncidentController.java:
package com.nextwork.incidentapi;
import java.util.Comparator;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/incidents")
public class IncidentController {
private final IncidentRepository repository;
public IncidentController(IncidentRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Incident> all() {
return repository.findAll().stream()
.sorted(Comparator.comparing(Incident::getId))
.toList();
}
}
How does the endpoint work?
- The controller mapping places every method under /api/incidents.
- Constructor injection gives the controller its repository dependency.
- The GET method reads every incident before sorting the records by ID.
- Spring converts the returned incident list into JSON for the browser.
- Save IncidentController.java.
- Return to the PowerShell terminal with DB_PASSWORD already set.
Before you start the backend, how many incident objects do you expect the endpoint to return? Hold your answer in mind.
- Restart the backend by running:
.\mvnw.cmd spring-boot:run
What should startup prove?
The application now validates the entity against the migrated table. A successful startup proves that the Java mapping and PostgreSQL schema agree.
The terminal should finish with Started IncidentApiApplication and no database connection error.
Backend does not start?
Confirm that this PowerShell session still has DB_PASSWORD set. Check the first reported file for a Java syntax error or a schema mismatch.
If startup still fails, help me diagnose the Spring Boot startup error.
- Open a browser tab.
- Enter http://localhost:8080/api/incidents in the address bar.
- Press Enter.
You should see a JSON array containing six records in order from INC-101 through INC-106.
That is the backend contract working. PostgreSQL now supplies the incident report through a live Java API.
Use this reference to compare every backend file created so far.
✔️ Awesome, I've got everything!
Great. Save every file before moving on.
ⓧ I'd like to double check the full code
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>
<groupId>com.nextwork</groupId>
<artifactId>incident-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>incident-api</name>
<description>PostgreSQL backend for the Security Incident Console</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
spring.application.name=incident-api
spring.datasource.url=jdbc:postgresql://localhost:5432/incident_console
spring.datasource.username=postgres
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
package com.nextwork.incidentapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IncidentApiApplication {
public static void main(String[] args) {
SpringApplication.run(IncidentApiApplication.class, args);
}
}
CREATE TABLE incidents (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(120) NOT NULL,
type VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL,
location VARCHAR(120) NOT NULL,
incident_time VARCHAR(5) NOT NULL,
description VARCHAR(500) NOT NULL,
CONSTRAINT incidents_type_check
CHECK (type IN ('Alarm', 'Door activity', 'Camera event')),
CONSTRAINT incidents_status_check
CHECK (status IN ('Active', 'Acknowledged', 'Resolved'))
);
INSERT INTO incidents (id, title, type, status, location, incident_time, description) VALUES
('INC-101', 'Forced door detected', 'Door activity', 'Active', 'North entrance', '09:42', 'The north entrance reported a forced-open state.'),
('INC-102', 'Motion after hours', 'Camera event', 'Acknowledged', 'Warehouse aisle 4', '09:36', 'Motion was detected outside the configured operating window.'),
('INC-103', 'Badge access denied', 'Door activity', 'Resolved', 'Research lab', '09:28', 'A credential was denied at the research lab reader.'),
('INC-104', 'Perimeter alarm triggered', 'Alarm', 'Active', 'West fence', '09:17', 'A perimeter sensor entered its active alarm state.'),
('INC-105', 'Camera signal restored', 'Camera event', 'Resolved', 'Parking level 2', '09:03', 'The camera resumed sending a healthy signal.'),
('INC-106', 'Emergency exit opened', 'Alarm', 'Active', 'South stairwell', '08:55', 'The emergency exit opened while the area was secured.');
package com.nextwork.incidentapi;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "incidents")
public class Incident {
@Id
@Column(length = 20)
private String id;
@Column(nullable = false, length = 120)
private String title;
@Column(nullable = false, length = 40)
private String type;
@Column(nullable = false, length = 20)
private String status;
@Column(nullable = false, length = 120)
private String location;
@Column(name = "incident_time", nullable = false, length = 5)
private String time;
@Column(nullable = false, length = 500)
private String description;
protected Incident() {
}
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public String getType() {
return type;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getLocation() {
return location;
}
public String getTime() {
return time;
}
public String getDescription() {
return description;
}
}
package com.nextwork.incidentapi;
import org.springframework.data.jpa.repository.JpaRepository;
public interface IncidentRepository extends JpaRepository<Incident, String> {
}
package com.nextwork.incidentapi;
import java.util.Comparator;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/incidents")
public class IncidentController {
private final IncidentRepository repository;
public IncidentController(IncidentRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Incident> all() {
return repository.findAll().stream()
.sorted(Comparator.comparing(Incident::getId))
.toList();
}
}
Your API now serves the complete incident report from PostgreSQL. Next, the React console will request these records and reveal the browser's cross-origin security boundary.
Load Incidents from React
Your Spring Boot REST API now serves six incidents from PostgreSQL in a stable order.
The React console still reads initialIncidents. Browser actions never ask the database for fresh data.
You will replace that local seed with a request that runs after the component mounts. The browser will reveal whether the frontend origin can read the backend response.
In this step, get ready to:
- Replace the local incident seed with a mount-time API request.
- Run the Vite frontend and inspect the first browser request.
Point React at the API
The API_BASE_URL constant gives the frontend one place to store the backend origin. The component can append its incident path when it makes a request.
- Leave the incident-api server terminal from earlier running.
- Open the existing incident-console folder in a second Visual Studio Code window.
- Select src/App.tsx in the Explorer sidebar.
- Update the React imports by replacing the first line with:
import { useEffect, useState } from 'react'
Why Import useEffect?
The useEffect hook runs synchronization logic after React renders the component. You will use it to request the incident report when the console mounts.
- Add the API origin directly below the closing brace of the Incident interface by pasting:
const API_BASE_URL = 'http://localhost:8080'
What Does This Constant Store?
The constant stores the origin of the running backend. Keeping the origin separate makes the request path easier to read inside loadIncidents().
- Save src/App.tsx.
- Open a terminal panel in the frontend Visual Studio Code window.
- Start the existing frontend development server by running:
npm run dev
What Does This Command Do?
The command starts the frontend's Vite development server. The process keeps control of this terminal while the server is active.
- Open http://localhost:5173 in your browser.
You should still see the original six incident rows. Your existing filters and triage controls remain available.
Good progress. This working baseline makes the effect of removing the local incident seed easy to spot.
Frontend Page Not Loading?
Confirm that the frontend terminal is inside the existing incident-console folder. Keep the backend terminal running in its separate window.
Check that the browser address is http://localhost:5173.
Help me diagnose why the frontend server or page is not loading.
Fetch incidents after mount
An effect can synchronize the console with an external data source after the first render. An empty dependency array makes this request run once for each mount.
- Return to src/App.tsx in the frontend window.
- Replace the current incidents state declaration with this empty typed state:
const [incidents, setIncidents] = useState<Incident[]>([])
Why Start With an Empty Array?
The empty array removes the local seed from the component state. Incident rows must now arrive through setIncidents.
- Save src/App.tsx.
- Return to the browser tab at http://localhost:5173.
You should see the No incidents match state with no incident rows. This confirms that the report no longer initializes from initialIncidents.
- Return to src/App.tsx.
- Delete the complete initialIncidents array declaration.
- Add the loading effect directly below the selectedId state declaration by pasting:
useEffect(() => {
let ignore = false
async function loadIncidents() {
const response = await fetch(`${API_BASE_URL}/api/incidents`)
if (!response.ok) throw new Error(`Incident request failed with ${response.status}`)
const data = (await response.json()) as Incident[]
if (!ignore) setIncidents(data)
}
loadIncidents().catch((error: unknown) => console.error('Unable to load incidents', error))
return () => {
ignore = true
}
}, [])
How Does the Request Work?
- The loadIncidents() function requests the incident endpoint after the component mounts.
- The response check stops invalid HTTP responses from becoming incident data.
- The JSON conversion treats the response body as an Incident[].
- The ignore flag prevents a completed request from updating an effect that has already been cleaned up.
Before you save, do you think the six database incidents will appear immediately when the browser makes its first request?
- Save src/App.tsx.
- Return to the browser tab at http://localhost:5173.
The report still has no incident rows. This shortfall is the intended result of the first request.
- Open your browser's developer tools.
- Select the console panel.
You will see a message explaining that the request to http://localhost:8080/api/incidents was blocked by CORS.
This Failure Is Intentional
The frontend uses port 5173. The backend uses port 8080.
Those ports create different browser origins. The backend has not granted the frontend permission to read its response.
The empty report proves that React now depends on the API. The browser error exposes the security boundary that the next step resolves.
Not Seeing the Planned Failure?
If six rows remain visible, confirm that the incidents state starts with []. Confirm that the initialIncidents declaration is gone.
If the console has no request message, confirm that useEffect calls loadIncidents().
Help me find why my React frontend is not showing the expected CORS failure.
✔️ I see the CORS failure
Your frontend request is in place. Keep the backend and frontend servers running for the next step.
ⓧ I'd like to double check the full code
- Compare your complete src/App.tsx file with this cumulative version:
import { useEffect, useState } from 'react'
type IncidentType = 'Alarm' | 'Door activity' | 'Camera event'
type IncidentStatus = 'Active' | 'Acknowledged' | 'Resolved'
type ViewMode = 'List' | 'Cards'
interface Incident {
id: string
title: string
type: IncidentType
status: IncidentStatus
location: string
time: string
description: string
}
const API_BASE_URL = 'http://localhost:8080'
function StatusBadge({ status }: { status: IncidentStatus }) {
return <span className={`status status-${status.toLowerCase()}`}>{status}</span>
}
function App() {
const [incidents, setIncidents] = useState<Incident[]>([])
const [typeFilter, setTypeFilter] = useState<'All' | IncidentType>('All')
const [statusFilter, setStatusFilter] = useState<'All' | IncidentStatus>('All')
const [viewMode, setViewMode] = useState<ViewMode>('List')
const [selectedId, setSelectedId] = useState<string | null>(null)
useEffect(() => {
let ignore = false
async function loadIncidents() {
const response = await fetch(`${API_BASE_URL}/api/incidents`)
if (!response.ok) throw new Error(`Incident request failed with ${response.status}`)
const data = (await response.json()) as Incident[]
if (!ignore) setIncidents(data)
}
loadIncidents().catch((error: unknown) => console.error('Unable to load incidents', error))
return () => {
ignore = true
}
}, [])
const filteredIncidents = incidents.filter(
(incident) =>
(typeFilter === 'All' || incident.type === typeFilter) &&
(statusFilter === 'All' || incident.status === statusFilter),
)
const activeCount = incidents.filter((incident) => incident.status === 'Active').length
const selectedIncident = incidents.find((incident) => incident.id === selectedId) ?? null
function acknowledgeSelected() {
if (!selectedIncident || selectedIncident.status !== 'Active') return
setIncidents((current) => current.map((incident) => incident.id === selectedIncident.id ? { ...incident, status: 'Acknowledged' } : incident))
}
return (
<div className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">Operations workspace</p>
<h1>Security incident console</h1>
</div>
<div className="metric"><span>{activeCount}</span>active incidents</div>
</header>
<main>
<section className="filters" aria-label="Report controls">
<label>
Type
<select value={typeFilter} onChange={(event) => setTypeFilter(event.currentTarget.value as 'All' | IncidentType)}>
<option value="All">All types</option>
<option value="Alarm">Alarm</option>
<option value="Door activity">Door activity</option>
<option value="Camera event">Camera event</option>
</select>
</label>
<label>
Status
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as 'All' | IncidentStatus)}>
<option value="All">All statuses</option>
<option value="Active">Active</option>
<option value="Acknowledged">Acknowledged</option>
<option value="Resolved">Resolved</option>
</select>
</label>
<label>
View
<select value={viewMode} onChange={(event) => setViewMode(event.currentTarget.value as ViewMode)}>
<option value="List">List</option>
<option value="Cards">Cards</option>
</select>
</label>
</section>
<div className={`workspace${selectedIncident ? ' has-details' : ''}`}>
<section className="results">
<div className="results-heading">
<div><p className="eyebrow">Current report</p><h2>Incidents</h2></div>
<p>{filteredIncidents.length} shown</p>
</div>
{filteredIncidents.length === 0 ? (
<div className="empty-state">
<h3>No incidents match</h3>
<p>Change one of the report filters to restore results.</p>
</div>
) : viewMode === 'List' ? (
<div className="table-wrap">
<table>
<caption>Security incidents matching the current filters</caption>
<thead>
<tr>
<th scope="col">Incident</th><th scope="col">Type</th><th scope="col">Location</th><th scope="col">Time</th><th scope="col">Status</th>
</tr>
</thead>
<tbody>
{filteredIncidents.map((incident) => (
<tr key={incident.id}>
<td><button type="button" className="link-button" onClick={() => setSelectedId(incident.id)}>{incident.id}: {incident.title}</button></td>
<td>{incident.type}</td><td>{incident.location}</td><td>{incident.time}</td><td><StatusBadge status={incident.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="card-grid">
{filteredIncidents.map((incident) => (
<article className="incident-card" key={incident.id}>
<div className="card-heading"><span>{incident.id}</span><StatusBadge status={incident.status} /></div>
<h3>{incident.title}</h3><p>{incident.location}</p><p className="card-meta">{incident.type} at {incident.time}</p>
<button type="button" onClick={() => setSelectedId(incident.id)}>View details</button>
</article>
))}
</div>
)}
</section>
{selectedIncident && (
<aside className="details-panel">
<div className="details-heading">
<div><p className="eyebrow">Incident details</p><h2>{selectedIncident.id}</h2></div>
<button type="button" className="secondary" onClick={() => setSelectedId(null)}>Close details</button>
</div>
<StatusBadge status={selectedIncident.status} />
<h3>{selectedIncident.title}</h3><p>{selectedIncident.description}</p>
<dl>
<div><dt>Type</dt><dd>{selectedIncident.type}</dd></div>
<div><dt>Location</dt><dd>{selectedIncident.location}</dd></div>
<div><dt>Time</dt><dd>{selectedIncident.time}</dd></div>
</dl>
<button type="button" className="primary" disabled={selectedIncident.status !== 'Active'} onClick={acknowledgeSelected}>
{selectedIncident.status === 'Active' ? 'Acknowledge incident' : 'No action required'}
</button>
</aside>
)}
</div>
</main>
</div>
)
}
export default App
What Should Match?
- The import includes useEffect beside useState.
- The API_BASE_URL points to the running backend.
- The incident state starts as an empty Incident[].
- The mount effect calls loadIncidents() and sends the response data to setIncidents.
Your React console now depends on the backend request. Next, you will grant the frontend a narrow cross-origin policy before moving acknowledgement into PostgreSQL.
Persist Incident Acknowledgements
Your React console now asks the backend for incidents. Browser CORS protection blocks the response.
Once reads work, the acknowledgement action still updates only browser memory. This step moves that transition into the Spring Boot API so PostgreSQL can preserve it across refreshes.
In this step, get ready to:
- Permit the frontend's incident requests through a narrow CORS policy.
- Add a guarded backend endpoint for acknowledging active incidents.
- Connect the acknowledgement button to the API and prove the change persists.
Add a narrow CORS policy
A CORS policy tells the browser which frontend origin may call the API. Limiting the policy to one origin plus two methods keeps the boundary focused on this console.
- Create WebConfiguration.java inside incident-api/src/main/java/com/nextwork/incidentapi through the Visual Studio Code Explorer.
- Build the CORS policy by pasting this code into WebConfiguration.java:
package com.nextwork.incidentapi;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "PUT");
}
}
What does this code do?
- The WebMvcConfigurer implementation customizes request handling for the API.
- The /api/** mapping applies the policy to every incident API route.
- The allowed origin limits browser access to http://localhost:5173.
- The method list permits incident reads with GET plus acknowledgement updates with PUT.
- Save WebConfiguration.java.
- Stop the running backend process in its PowerShell terminal.
- Restart the backend from the existing incident-api terminal by running:
.\mvnw.cmd spring-boot:run
What does this command do?
The Maven Wrapper starts the Spring Boot application with the project's configured dependencies. Restarting activates the new request policy.
Before you refresh, do you think the browser will accept the same incident request that it blocked earlier?
- Return to the frontend at http://localhost:5173.
- Refresh the browser.
You will see six incident rows from INC-101 through INC-106. The planned CORS rejection no longer blocks the report.
You have crossed the browser boundary. The database records can now reach the console through the origin you approved.
Still seeing an empty report?
Confirm that the backend restarted after you saved WebConfiguration.java. Check that the configured origin matches the frontend address exactly.
Make sure WebConfiguration.java uses the com.nextwork.incidentapi package so Spring Boot discovers it.
Help me diagnose why my React request is still blocked after adding WebConfiguration.
✔️ My CORS file matches
Your backend now accepts the frontend's incident reads. Keep the restarted server running.
ⓧ I'd like to double check the full code
package com.nextwork.incidentapi;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "PUT");
}
}
What should match?
Compare the package plus the API mapping carefully. The origin must match the running frontend address.
Add the acknowledgement endpoint
The backend must own the status transition so every client follows the same rule. The endpoint accepts an active incident before saving its new status through Spring Data JPA.
- Return to IncidentController.java in the existing incident-api editor.
- Add the four imports from the top of this snippet to the existing import section.
- Add acknowledge() below the existing all() method using these additions:
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.server.ResponseStatusException;
@PutMapping("/{id}/acknowledge")
public Incident acknowledge(@PathVariable String id) {
Incident incident = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Incident " + id + " was not found"));
if (!"Active".equals(incident.getStatus())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Only active incidents can be acknowledged");
}
incident.setStatus("Acknowledged");
return repository.save(incident);
}
How does the endpoint protect the transition?
- The route captures the incident id from the request path.
- The repository lookup returns 404 when the requested record does not exist.
- The status guard returns 409 when the record is already acknowledged or resolved.
- The repository saves the valid Acknowledged status in PostgreSQL before returning the updated incident.
- Save IncidentController.java.
- Stop the backend process in its PowerShell terminal.
- Start the updated API by running:
.\mvnw.cmd spring-boot:run
What does this restart confirm?
The Maven Wrapper recompiles the controller before starting the application. A successful startup confirms that the new route plus its imports compile.
You will see the log finish with Started IncidentApiApplication and no compilation error.
Backend failed to restart?
Check that each new import sits below the existing Java imports. Confirm that acknowledge() remains inside the IncidentController class.
Compare the braces around all() before checking the new method. A misplaced class brace can leave the endpoint outside the controller.
Help me fix the compilation problem in my IncidentController acknowledgement endpoint.
✔️ My controller compiles
Your API now owns the guarded status transition. Keep the backend running for the frontend test.
ⓧ I'd like to double check the full code
package com.nextwork.incidentapi;
import java.util.Comparator;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/incidents")
public class IncidentController {
private final IncidentRepository repository;
public IncidentController(IncidentRepository repository) {
this.repository = repository;
}
@GetMapping
public List<Incident> all() {
return repository.findAll().stream()
.sorted(Comparator.comparing(Incident::getId))
.toList();
}
@PutMapping("/{id}/acknowledge")
public Incident acknowledge(@PathVariable String id) {
Incident incident = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Incident " + id + " was not found"));
if (!"Active".equals(incident.getStatus())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Only active incidents can be acknowledged");
}
incident.setStatus("Acknowledged");
return repository.save(incident);
}
}
What should match?
The completed controller keeps the sorted read endpoint. It adds one guarded update endpoint inside the same class.
Connect the acknowledgement button
The API returns the incident after PostgreSQL saves it. Using that response keeps the browser state aligned with the database state.
- Switch back to incident-console/src/App.tsx in the existing frontend editor.
- Locate the existing acknowledgeSelected() function.
- Replace the complete function with this asynchronous version:
async function acknowledgeSelected() {
if (!selectedIncident || selectedIncident.status !== 'Active') return
try {
const response = await fetch(
`${API_BASE_URL}/api/incidents/${selectedIncident.id}/acknowledge`,
{ method: 'PUT' },
)
if (!response.ok) throw new Error(`Acknowledgement failed with ${response.status}`)
const updatedIncident = (await response.json()) as Incident
setIncidents((current) =>
current.map((incident) =>
incident.id === updatedIncident.id ? updatedIncident : incident,
),
)
} catch (error: unknown) {
console.error('Unable to acknowledge incident', error)
}
}
What does this function do?
- The opening guard ignores missing selections plus incidents that are not active.
- The fetch() call sends a PUT request for the selected incident.
- The response check prevents a failed request from being treated as a successful update.
- The state update replaces only the incident returned by the backend.
- Save incident-console/src/App.tsx.
Before you acknowledge INC-104, do you expect its new status to survive a browser refresh?
- Return to the frontend at http://localhost:5173.
- Refresh the browser.
- Select INC-104 from the incident list.
- Click Acknowledge incident in the details panel.
- Refresh the browser again.
You will see INC-104 still marked Acknowledged. The header will still report 2 active incidents.
That is the full persistence loop working. The console now reloads the saved PostgreSQL state after every refresh.
Acknowledgement did not persist?
Confirm that both development servers are still running. Check the browser console for a failed request after clicking the acknowledgement button.
Make sure the frontend sends PUT to the selected incident's acknowledgement route. Confirm that the CORS policy also permits PUT.
Help me trace why an incident acknowledgement disappears after I refresh the React console.
✔️ My acknowledgement persists
Your console now reads incidents from PostgreSQL and saves valid acknowledgements through the backend.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react'
type IncidentType = 'Alarm' | 'Door activity' | 'Camera event'
type IncidentStatus = 'Active' | 'Acknowledged' | 'Resolved'
type ViewMode = 'List' | 'Cards'
interface Incident {
id: string
title: string
type: IncidentType
status: IncidentStatus
location: string
time: string
description: string
}
const API_BASE_URL = 'http://localhost:8080'
function StatusBadge({ status }: { status: IncidentStatus }) {
return <span className={`status status-${status.toLowerCase()}`}>{status}</span>
}
function App() {
const [incidents, setIncidents] = useState<Incident[]>([])
const [typeFilter, setTypeFilter] = useState<'All' | IncidentType>('All')
const [statusFilter, setStatusFilter] = useState<'All' | IncidentStatus>('All')
const [viewMode, setViewMode] = useState<ViewMode>('List')
const [selectedId, setSelectedId] = useState<string | null>(null)
useEffect(() => {
let ignore = false
async function loadIncidents() {
const response = await fetch(`${API_BASE_URL}/api/incidents`)
if (!response.ok) throw new Error(`Incident request failed with ${response.status}`)
const data = (await response.json()) as Incident[]
if (!ignore) setIncidents(data)
}
loadIncidents().catch((error: unknown) => console.error('Unable to load incidents', error))
return () => {
ignore = true
}
}, [])
const filteredIncidents = incidents.filter(
(incident) =>
(typeFilter === 'All' || incident.type === typeFilter) &&
(statusFilter === 'All' || incident.status === statusFilter),
)
const activeCount = incidents.filter((incident) => incident.status === 'Active').length
const selectedIncident = incidents.find((incident) => incident.id === selectedId) ?? null
async function acknowledgeSelected() {
if (!selectedIncident || selectedIncident.status !== 'Active') return
try {
const response = await fetch(
`${API_BASE_URL}/api/incidents/${selectedIncident.id}/acknowledge`,
{ method: 'PUT' },
)
if (!response.ok) throw new Error(`Acknowledgement failed with ${response.status}`)
const updatedIncident = (await response.json()) as Incident
setIncidents((current) =>
current.map((incident) =>
incident.id === updatedIncident.id ? updatedIncident : incident,
),
)
} catch (error: unknown) {
console.error('Unable to acknowledge incident', error)
}
}
return (
<div className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">Operations workspace</p>
<h1>Security incident console</h1>
</div>
<div className="metric"><span>{activeCount}</span>active incidents</div>
</header>
<main>
<section className="filters" aria-label="Report controls">
<label>
Type
<select value={typeFilter} onChange={(event) => setTypeFilter(event.currentTarget.value as 'All' | IncidentType)}>
<option value="All">All types</option>
<option value="Alarm">Alarm</option>
<option value="Door activity">Door activity</option>
<option value="Camera event">Camera event</option>
</select>
</label>
<label>
Status
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as 'All' | IncidentStatus)}>
<option value="All">All statuses</option>
<option value="Active">Active</option>
<option value="Acknowledged">Acknowledged</option>
<option value="Resolved">Resolved</option>
</select>
</label>
<label>
View
<select value={viewMode} onChange={(event) => setViewMode(event.currentTarget.value as ViewMode)}>
<option value="List">List</option>
<option value="Cards">Cards</option>
</select>
</label>
</section>
<div className={`workspace${selectedIncident ? ' has-details' : ''}`}>
<section className="results">
<div className="results-heading">
<div><p className="eyebrow">Current report</p><h2>Incidents</h2></div>
<p>{filteredIncidents.length} shown</p>
</div>
{filteredIncidents.length === 0 ? (
<div className="empty-state">
<h3>No incidents match</h3>
<p>Change one of the report filters to restore results.</p>
</div>
) : viewMode === 'List' ? (
<div className="table-wrap">
<table>
<caption>Security incidents matching the current filters</caption>
<thead>
<tr>
<th scope="col">Incident</th><th scope="col">Type</th><th scope="col">Location</th><th scope="col">Time</th><th scope="col">Status</th>
</tr>
</thead>
<tbody>
{filteredIncidents.map((incident) => (
<tr key={incident.id}>
<td><button type="button" className="link-button" onClick={() => setSelectedId(incident.id)}>{incident.id}: {incident.title}</button></td>
<td>{incident.type}</td><td>{incident.location}</td><td>{incident.time}</td><td><StatusBadge status={incident.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="card-grid">
{filteredIncidents.map((incident) => (
<article className="incident-card" key={incident.id}>
<div className="card-heading"><span>{incident.id}</span><StatusBadge status={incident.status} /></div>
<h3>{incident.title}</h3><p>{incident.location}</p><p className="card-meta">{incident.type} at {incident.time}</p>
<button type="button" onClick={() => setSelectedId(incident.id)}>View details</button>
</article>
))}
</div>
)}
</section>
{selectedIncident && (
<aside className="details-panel">
<div className="details-heading">
<div><p className="eyebrow">Incident details</p><h2>{selectedIncident.id}</h2></div>
<button type="button" className="secondary" onClick={() => setSelectedId(null)}>Close details</button>
</div>
<StatusBadge status={selectedIncident.status} />
<h3>{selectedIncident.title}</h3><p>{selectedIncident.description}</p>
<dl>
<div><dt>Type</dt><dd>{selectedIncident.type}</dd></div>
<div><dt>Location</dt><dd>{selectedIncident.location}</dd></div>
<div><dt>Time</dt><dd>{selectedIncident.time}</dd></div>
</dl>
<button type="button" className="primary" disabled={selectedIncident.status !== 'Active'} onClick={() => void acknowledgeSelected()}>
{selectedIncident.status === 'Active' ? 'Acknowledge incident' : 'No action required'}
</button>
</aside>
)}
</div>
</main>
</div>
)
}
export default App
What should match?
The completed component keeps its filters plus both report views. Its load effect reads incidents while acknowledgeSelected() saves the selected transition.
Secret mission
Timestamp Every Acknowledgement
Your database records the latest incident status, but an audit trail also needs the time of each transition. Extend the schema so every new acknowledgement includes a timestamp in the API response.
Clean Up Your Resources
Clean Up Your Resources
Your completed system runs locally with no ongoing monetary charges. Decide whether to keep its processes active, pause them for later, or remove the local resources.
Resources you used:
- The local incident_console database in PostgreSQL.
- The local incident-api project in Spring Boot.
- The REST integration inside the React file incident-console/src/App.tsx.
Keep everything running
No action needed. Choose this if you want to demonstrate the persistent incident workflow again.
- Keep the Spring Boot process running at http://localhost:8080.
- Keep the Vite development server running at http://localhost:5173.
- Keep PostgreSQL running so the persisted acknowledgement statuses remain available.
Pause - I'll come back to this later
This option stops the two development servers while preserving your local project state.
- In the incident-api terminal, press Ctrl+C to stop Spring Boot.
- In the frontend terminal, press Ctrl+C to stop Vite.
- Keep the incident_console database for the next session.
- Keep the incident-api folder for the next session.
- Keep the incident-console folder for the next session.
Delete - I don't want to use this again
Database deletion is permanent. This option only removes the local resources created or changed for this project.
- In the incident-api terminal, press Ctrl+C to stop Spring Boot.
- In the frontend terminal, press Ctrl+C to stop Vite.
- Return to pgAdmin from earlier to remove the project database.
- Connect pgAdmin to the postgres database.
- Delete the incident_console database by running this statement in pgAdmin:
DROP DATABASE IF EXISTS incident_console WITH (FORCE);
What Does This Command Remove?
This statement removes the incident_console database. The deletion cannot be undone.
PostgreSQL requires the statement to run from another database. The postgres connection provides that required starting point.
- Refresh the Databases node to confirm that incident_console no longer appears.
- In Visual Studio Code, restore incident-console/src/App.tsx from your completed Build a Security Incident Console project.
- Confirm that the restored file no longer contains API_BASE_URL.
- Use File Explorer to delete the incident-api folder from the location where Spring Initializr generated it.
- Confirm that the incident-api folder is no longer listed in that location.
Nice Work!
Nice Work!
You did it! Your React security console now reads incidents from a Spring Boot API backed by PostgreSQL.
Acknowledgements now survive browser refreshes. Operators can trust the console to reflect the database.
You learned how to:
- Connect a React console to a REST API that loads six incidents while preserving the existing filters, views, details panel, and active count.
- Manage a repeatable PostgreSQL schema with Flyway migrations. Map the incident records through JPA without handwritten database queries.
- Build a persistent triage workflow that enforces a finite CORS policy. Guard the Active-to-Acknowledged transition in the backend.
- Secret Mission: Extended the database with an acknowledgement timestamp through a forward-only migration. Returned the timestamp in the incident JSON.
Ready to quiz yourself?