Build a Visitor Management System
Build a full-stack microservices app with Java, Python, React, and AWS.
Introduction
30 Second Summary
Every gated community relies on a guard checking IDs, calling homeowners, and scribbling visit logs into a paper notebook. One missed entry or lost record can mean unauthorized access or a frustrated homeowner who never got notified about their guest.
In this project, you will build GateGuard, a full-stack subdivision visitor management system that automates visitor logging with OCR document scanning, sends instant notifications to homeowners, processes HOA payments, and generates AI-powered community analytics.
What You'll Build
A complete subdivision management platform spanning Java microservices, a Python OCR service, a React admin dashboard, and a React Native mobile app, all deployed to AWS.
By the end of this project, you'll have:
- A Spring Boot backend with JWT authentication and role-based access control for guards, homeowners, and admins.
- A Python FastAPI microservice using PaddleOCR for automated ID document text extraction.
- A React Native mobile app and React web dashboard connected to system design infrastructure including Redis caching, RabbitMQ message queues, Stripe payments, and Prometheus monitoring.
- Secret Mission: real-time WebSocket notifications that push visitor alerts to homeowners instantly, with a live activity feed on the admin dashboard.
Are there any prerequisites?
You should have experience building Spring Boot REST APIs. No React or React Native experience is required because the project teaches both from scratch.
Basic SQL knowledge is helpful. You will also need a Mac with at least 24GB of RAM to run all services locally.
Set Up Core Development Tools
GateGuard is a multi-service platform built with Java, Python, React, and React Native. Before writing a single line of code, you need a solid foundation of development tools installed and configured on your Mac.
In this step, you'll install everything from the Java runtime to Docker containers that power your databases and message queues. By the end, your machine will be fully equipped to build each piece of the GateGuard architecture.
In this step, get ready to:
- Install core development tools (JDK 21, Maven, Git, Python 3.12, Node.js) via Homebrew.
- Set up IntelliJ IDEA and VS Code with extensions for Java, React, and Python development.
- Launch PostgreSQL, Redis, and RabbitMQ containers in Docker.
Install Homebrew and core development packages
Homebrew is a package manager for macOS that lets you install developer tools with a single terminal command. You'll use it to install every core tool GateGuard needs.
- Press Cmd+Space (macOS) to open your search bar.
- Type Terminal and press Enter to open it.
- Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
What does this command do?
This downloads and runs the official Homebrew installer script. It will ask for your Mac password and may prompt you to press Enter to confirm. Follow the on-screen instructions.
- After installation completes, follow any instructions the installer shows (it may ask you to run two commands to add Homebrew to your PATH).
- Verify Homebrew is working by running:
brew --version
You should see a version number like Homebrew 4.x.x in the output.
Command not found?
If you see brew: command not found, your terminal doesn't know where Homebrew lives yet. Run the two commands the installer printed at the end (they start with echo and eval), then try brew --version again.
help me fix brew command not found on macOS
Now you'll install the core development tools GateGuard depends on. Each package serves a specific role in the architecture.
- Install JDK 21, Maven, Git, Python 3.12, and Node.js LTS by running this command:
brew install openjdk@21 maven git python@3.12 node
What is each tool for?
- openjdk@21 is the Java Development Kit that runs the Spring Boot backend.
- maven is Java's build tool for managing dependencies and compiling projects.
- git is version control for tracking code changes.
- python@3.12 runs the PaddleOCR microservice built with FastAPI.
- node is the JavaScript runtime for React, React Native, and Expo tooling.
- After installation completes, symlink JDK 21 so your system can find it by running:
sudo ln -sfn $(brew --prefix)/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
- Verify each tool installed correctly by running these version commands:
java --version
mvn --version
git --version
python3.12 --version
node --version
You should see version output for each tool. Confirm java shows 21, python3.12 shows 3.12.x, and node shows a version starting with 20 or 22.
Seeing a different Java version?
If java --version shows a version other than 21, you may have multiple JDKs installed. Make sure you ran the sudo ln -sfn symlink command above. You can also set JAVA_HOME in your shell profile to point to JDK 21.
help me set JAVA_HOME to JDK 21 on macOS
- Install Docker Desktop by running:
brew install --cask docker
- Press Cmd+Space to open your search bar.
- Type Docker and press Enter to launch Docker Desktop.
- Wait for Docker Desktop to finish starting. You'll see a whale icon appear in your menu bar.
- Verify Docker is running by running:
docker --version
You should see a version number like Docker version 27.x.x.
Docker command not responding?
Docker Desktop must be fully launched before CLI commands work. Look for the whale icon in your menu bar. If it's still animating, wait for it to become solid. If Docker Desktop asks you to accept terms of service, do so before continuing.
help me get Docker Desktop running on macOS
Install IntelliJ IDEA and VS Code
GateGuard uses two IDEs. IntelliJ IDEA is purpose-built for Java development with Spring Boot support. VS Code handles the React, React Native, and Python portions of the project.
- Install IntelliJ IDEA Community Edition by running:
brew install --cask intellij-idea-ce
- Install VS Code by running:
brew install --cask visual-studio-code
- Press Cmd+Space to open your search bar.
- Type Visual Studio Code and press Enter to open it.
VS Code extensions add language support and formatting tools that make development faster. You'll install three extensions for the project.
- Install the three VS Code extensions by running these commands in your terminal:
code --install-extension dsznajder.es7-react-js-snippets
code --install-extension ms-python.python
code --install-extension esbenp.prettier-vscode
What do these extensions do?
- ES7+ React snippets gives you shortcut commands to generate React component boilerplate instantly.
- Python adds IntelliSense, linting, and debugging support for the FastAPI microservice.
- Prettier auto-formats your JavaScript, TypeScript, and CSS files on save.
- Verify extensions installed by running:
code --list-extensions
You should see dsznajder.es7-react-js-snippets, ms-python.python, and esbenp.prettier-vscode in the output list.
code command not found?
If code is not recognized, you need to add VS Code to your PATH. Open VS Code manually, press Cmd+Shift+P, type Shell Command: Install 'code' command in PATH, and press Enter. Then restart your terminal.
help me set up the code command for VS Code on macOS
Launch database and messaging containers with Docker
GateGuard uses three infrastructure services that run as Docker containers. PostgreSQL stores all application data. Redis provides fast caching for frequently accessed queries. RabbitMQ handles message queues for sending notifications between services.
- Start the PostgreSQL container with the gateguard database by running:
docker run --name gateguard-postgres -e POSTGRES_PASSWORD=gateguard -e POSTGRES_DB=gateguard -p 5432:5432 -d postgres:16
What does this command do?
- --name gateguard-postgres gives the container a memorable name so you can manage it later.
- -e POSTGRES_PASSWORD=gateguard sets the database password.
- -e POSTGRES_DB=gateguard automatically creates a database named gateguard when the container starts.
- -p 5432:5432 maps the container's PostgreSQL port to your Mac's port 5432.
- -d postgres:16 runs PostgreSQL version 16 in the background.
- Start the Redis container by running:
docker run --name gateguard-redis -p 6379:6379 -d redis:7
What does this command do?
This starts Redis version 7 on port 6379. Redis runs entirely in memory, so it starts almost instantly. Your Spring Boot backend will connect to it on this port for caching.
- Start the RabbitMQ container with the management UI enabled by running:
docker run --name gateguard-rabbitmq -p 5672:5672 -p 15672:15672 -d rabbitmq:3-management
What does this command do?
This starts RabbitMQ version 3 with two ports. Port 5672 is where your backend sends and receives messages. Port 15672 exposes a web-based management UI where you can monitor queues and messages visually.
- Verify all three containers are running by running:
docker ps
You should see three containers listed with STATUS showing Up: gateguard-postgres, gateguard-redis, and gateguard-rabbitmq.
- Confirm the gateguard database was created by connecting to PostgreSQL:
docker exec -it gateguard-postgres psql -U postgres -d gateguard -c '\conninfo'
You should see a message confirming you are connected to database gateguard as user postgres.
- Verify the RabbitMQ management UI is accessible by opening your browser and navigating to:
http://localhost:15672
You should see the RabbitMQ login page. Sign in with username guest and password guest to confirm the management dashboard loads.
Container exited immediately?
If docker ps shows fewer than three containers, one may have failed to start. Run docker ps -a to see all containers including stopped ones. Check the logs with docker logs gateguard-postgres (replace with the container name that failed).
A common cause is a port conflict. If another service is already using port 5432, 6379, or 15672, the container cannot bind to it. Stop the conflicting service or choose a different host port.
help me fix my Docker container that exited immediately
Restarting containers after a reboot
Docker containers stop when you restart your Mac or quit Docker Desktop. To bring them back up later, run docker start gateguard-postgres gateguard-redis gateguard-rabbitmq. Your data persists between restarts.
Your development environment is fully set up. You have Java, Python, Node.js, two IDEs with extensions, and three infrastructure containers running locally. Next up, you'll install the AI and OCR tools, then connect to cloud services.
Set Up AI, OCR, and Cloud Tools
Your development environment is ready with Java, Python, Node.js, and Docker all running. Now you need the AI and cloud services that power GateGuard's intelligent features.
GateGuard uses a local AI model for summarizing complaints and analyzing visitor patterns. It also uses an OCR engine for reading text from visitor ID photos, and cloud services for storage and payments. In this step, you will install and configure all three.
In this step, get ready to:
- Install Ollama and pull the Llama 3.2 model for local AI inference.
- Set up a Python virtual environment with PaddleOCR and FastAPI for document scanning.
- Create AWS and Stripe accounts for cloud storage and payment processing.
Install Ollama and pull the Llama 3.2 model
GateGuard uses a local large language model to summarize homeowner complaints and generate visitor analytics reports. Ollama lets you run LLMs on your Mac with automatic Apple Silicon GPU acceleration.
- Install Ollama by running this command:
brew install ollama
- Start the Ollama server as a background service by running:
brew services start ollama
What does this do?
This registers Ollama as a background service that starts automatically at login. The server listens on port 11434 and handles requests from your Spring Boot backend later.
- Verify the service is running:
curl http://localhost:11434/api/tags
You should see a JSON response like {"models":[]} confirming the API is active.
Seeing "Connection refused"?
The service may take a few seconds to start. Wait 5 seconds and try the curl command again.
If it still fails, check the service status with brew services list and make sure ollama shows as "started".
help me troubleshoot why Ollama won't start on my Mac
Now you need to download the actual AI model. The Llama 3.2 8B model is approximately 5GB and takes a few minutes to download depending on your internet speed.
- Pull the Llama 3.2 model by running:
ollama pull llama3.2
What is Llama 3.2?
Llama 3.2 is Meta's open-source language model with 8 billion parameters. At 24GB RAM on Apple Silicon, it runs comfortably using Metal GPU acceleration.
GateGuard will use this model to summarize complaints, generate visitor reports, and produce dashboard insights.
- Once the download completes, test the model with a chat request by running:
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{"role": "user", "content": "Say hello in one word"}],
"stream": false
}'
You should see a JSON response containing the model's reply. This confirms Ollama is running and the Llama 3.2 model is loaded correctly.
Model not responding?
If you see an error about the model not being found, the pull may not have completed successfully. Run ollama list to verify llama3.2 appears in the output.
If it is not listed, run ollama pull llama3.2 again and wait for it to complete.
help me fix my Ollama model not loading
Set up PaddleOCR and FastAPI
GateGuard's OCR microservice runs as a separate Python application. It receives photos of visitor IDs, extracts the text using PaddleOCR, and returns structured data to the Spring Boot backend.
You will create an isolated Python virtual environment to keep these dependencies separate from your system Python.
- Move to your Desktop by running this command:
- Create a project folder for the OCR service and move into it:
cd ~/Desktop
mkdir gateguard-ocr
cd gateguard-ocr
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment:
source venv/bin/activate
Your terminal prompt should now show (venv) at the beginning, indicating the virtual environment is active.
What is a virtual environment?
A Python virtual environment is an isolated space where packages are installed independently from your system Python. This prevents version conflicts between projects.
You will always activate this environment before working on the OCR service.
- Install PaddleOCR with information extraction support by running:
python -m pip install "paddleocr[ie]"
Why the [ie] extra?
The [ie] install group adds information extraction capabilities. This lets PaddleOCR parse common ID document fields like names, dates, and addresses from the raw OCR text.
- Install FastAPI and uvicorn (the web server) by running:
pip install fastapi uvicorn
Installation taking a long time?
PaddleOCR downloads several large model files during installation. This can take 5-10 minutes on a typical connection.
If you see compilation errors, make sure you are running Python 3.12 inside the virtual environment by checking with python --version.
help me fix PaddleOCR installation errors on macOS
Now test that PaddleOCR works by running a quick extraction on a sample image.
- Create a test script by running:
cat > test_ocr.py << 'EOF'
from paddleocr import PaddleOCR
# Initialize PaddleOCR with minimal preprocessing
ocr = PaddleOCR(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False
)
# Run OCR on PaddleOCR's built-in sample image
result = ocr.predict("https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/general_ocr_002.png")
# Print detected text
for res in result:
res.print()
print("\nPaddleOCR is working correctly!")
EOF
- Run the test script:
python test_ocr.py
What does this test do?
The script downloads a sample image from PaddleOCR's public repository and runs text detection and recognition on it. On the first run, PaddleOCR downloads its model weights (a few hundred MB).
You should see detected text printed to the terminal along with confidence scores. The exact output depends on the sample image content.
You should see text output with detected words and confidence scores, followed by "PaddleOCR is working correctly!".
Seeing import errors?
Make sure your virtual environment is active. Your terminal prompt should start with (venv).
If not, run source venv/bin/activate from inside the gateguard-ocr folder and try again.
help me fix PaddleOCR import errors in my virtual environment
Create AWS and Stripe accounts
GateGuard stores visitor photos in Amazon S3 and processes HOA dues payments through Stripe. You need accounts on both platforms before building the integration.
What is the AWS Free Plan?
New AWS accounts receive $200 in credits ($100 at signup plus $100 earned through activities). These credits last for 6 months and cover all the services GateGuard uses.
At the scale of this project (t3.small EC2 and db.t3.micro RDS), you will stay well within the free credit budget.
- Open your browser and navigate to https://aws.amazon.com/free.
- Click Create a Free Account.
- Enter your email address and choose an AWS account name.
- Complete the registration form with your contact information and a payment method.
- Select the Free support plan when prompted.
Why do I need a payment method?
AWS requires a card on file for identity verification. You will not be charged as long as you stay within your free credits. The Before You Go section covers how to clean up resources to avoid charges.
Account activation can take up to 24 hours
Some AWS accounts activate immediately. Others require up to 24 hours for full activation. You can continue with the rest of this step while waiting.
- Install the AWS CLI by running:
brew install awscli
- Verify the installation:
aws --version
You should see a version string like aws-cli/2.x.x in the output.
- Configure the AWS CLI with your credentials by running:
aws configure
What will aws configure ask?
The command prompts for four values:
- AWS Access Key ID: Find this in the AWS Console under IAM > Users > your user > Security credentials > Create access key.
- AWS Secret Access Key: Shown once when you create the access key.
- Default region: Enter us-east-1.
- Default output format: Enter json.
- Verify your credentials work by running:
aws sts get-caller-identity
You should see JSON output with your account ID, user ARN, and user ID.
Seeing "Unable to locate credentials"?
Run aws configure again and make sure you paste the Access Key ID and Secret Access Key correctly. Do not add spaces or quotes around the values.
help me configure AWS CLI credentials on macOS
Next, create a Stripe account for payment processing.
- Open https://dashboard.stripe.com/register in your browser.
- Create an account with your email and a password.
- After logging in, look at the left sidebar for the Developers section.
- Click API keys.
- Note your Publishable key (starts with pk_test_) and Secret key (starts with sk_test_).
What is Stripe test mode?
Stripe test mode lets you simulate payments without real money. Any API key starting with sk_test_ or pk_test_ operates in test mode automatically.
You will use test card number 4242 4242 4242 4242 later to simulate successful payments.
Save your keys securely
Copy both Stripe keys to a secure note or password manager. You will add them to your Spring Boot configuration in a later step. Never commit API keys to Git.
Your AI, OCR, and cloud tools are all ready. Next up, you will design the database schema and create the Spring Boot project that ties everything together.
Design the Database Schema and Spring Boot Project
Your development environment is fully set up with all the tools GateGuard needs. Now it is time to build the foundation of the backend.
Every application needs a data model that captures the relationships between its core concepts. In this step, you will generate a Spring Boot project, design the database schema as JPA entities, and wire everything to your PostgreSQL database so that Hibernate creates your tables automatically.
In this step, get ready to:
- Generate a Spring Boot 3.5.x project with the required dependencies.
- Create JPA entity classes that define the GateGuard database schema.
- Configure the application to connect to PostgreSQL and verify Hibernate creates all tables.
Generate the Spring Boot project
Spring Initializr is a web tool that scaffolds a ready-to-code Spring Boot project with your chosen dependencies. Since IntelliJ IDEA Community Edition does not include the built-in Spring Boot wizard, you will use the web interface and then import the downloaded project.
- Open your browser and navigate to start.spring.io.
- Set Project to Maven.
- Set Language to Java.
- Under Spring Boot, select the latest 3.5.x version available (for example, 3.5.11).
- Under Project Metadata, set Group to com.gateguard.
- Set Artifact to gateguard.
- Set Name to gateguard.
- Set Java version to 21.
- Leave Packaging as Jar.
Now add the six dependencies GateGuard needs:
- Click ADD DEPENDENCIES (or press Ctrl+B).
- Search for and add Spring Web.
- Search for and add Spring Data JPA.
- Search for and add Spring Security.
- Search for and add PostgreSQL Driver.
- Search for and add Lombok.
- Search for and add Validation.
- Click GENERATE to download the project as a zip file.
What do these dependencies do?
Spring Web gives you REST controller support. Spring Data JPA provides the ORM layer that maps Java classes to database tables. Spring Security handles authentication and authorization (you will configure JWT in the next step).
PostgreSQL Driver lets Java connect to your PostgreSQL database. Lombok generates boilerplate code like getters and setters at compile time. Validation adds annotation-based input validation.
Next, extract the zip and import it into IntelliJ:
- Unzip the downloaded gateguard.zip file to your Desktop.
- Open IntelliJ IDEA.
- Click Open on the Welcome screen.
- Navigate to the gateguard folder on your Desktop and select the pom.xml file.
- When prompted, click Open as Project.
- If asked to trust the project, click Trust Project.
- Wait for IntelliJ to finish downloading dependencies (you will see a progress bar in the bottom status bar).
Once IntelliJ finishes indexing, you will see the Maven project structure with src/main/java and src/main/resources folders in the Project tool window on the left.
IntelliJ not recognizing the project?
Make sure you selected the pom.xml file (not the folder) when opening. If dependencies show red underlines, click the Maven reload icon in the Maven tool window (right sidebar) or press Ctrl+Shift+O (macOS: Cmd+Shift+O) to re-sync.
help me fix IntelliJ not loading my Maven Spring Boot project dependencies
Create the JPA entity classes
JPA entities are Java classes annotated with @Entity that map directly to database tables. Each field becomes a column, and Hibernate creates the tables for you at startup.
You will create eight entity classes inside a new entity package.
- In IntelliJ, expand src/main/java/com/gateguard in the Project tool window.
- Right-click the gateguard package, select New, then Package.
- Name the new package entity and press Enter.
Now create the first entity file. Right-click the entity package, select New then Java Class, and name it User.
- Replace the generated content in User.java with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Unique login name for authentication
@Column(unique = true, nullable = false)
private String username;
// BCrypt-hashed password stored securely
@Column(nullable = false)
private String password;
// Determines what the user can access: GUARD, HOMEOWNER, or ADMIN
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
private String email;
// Three roles in the GateGuard system
public enum Role {
GUARD, HOMEOWNER, ADMIN
}
}
What does this code do?
- @Entity tells Hibernate this class maps to a database table.
- @Table(name = "users") sets the table name explicitly (because "user" is a reserved word in PostgreSQL).
- @Data is a Lombok annotation that generates getters, setters, toString, equals, and hashCode.
- @Enumerated(EnumType.STRING) stores the role as a readable string in the database instead of a number.
- The inner Role enum defines the three access levels: guards log visitors, homeowners manage their properties, and admins oversee everything.
Create the next entity. Right-click the entity package, select New then Java Class, and name it Homeowner.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "homeowners")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Homeowner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Links this homeowner profile to their login account
@OneToOne
@JoinColumn(name = "user_id")
private User user;
// Block, lot, and phase identify the homeowner's property in the subdivision
private String blk;
private String lot;
private String phase;
private String fullName;
private String contactInfo;
}
What does this code do?
@OneToOne creates a one-to-one relationship between a homeowner profile and a user account. The blk, lot, and phase fields let guards quickly look up a homeowner by their property location when a visitor arrives.
Create a new Java class in the entity package named Visitor.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "visitors")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Visitor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// S3 URL for the photo of the visitor's ID document
private String idPhotoUrl;
// S3 URL for the live photo taken by the guard at the gate
private String livePhotoUrl;
// Text extracted from the ID by the OCR microservice
@Column(columnDefinition = "TEXT")
private String ocrData;
}
Why TEXT for ocrData?
OCR output can be lengthy and unpredictable in size. Using columnDefinition = "TEXT" tells PostgreSQL to use its unlimited-length text type instead of the default 255-character varchar.
Create a new Java class named VisitLog in the entity package.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "visit_logs")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VisitLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The visitor who checked in at the gate
@ManyToOne
@JoinColumn(name = "visitor_id")
private Visitor visitor;
// The homeowner being visited
@ManyToOne
@JoinColumn(name = "homeowner_id")
private Homeowner homeowner;
private String purpose;
private LocalDateTime timestamp;
// Tracks approval: PENDING_APPROVAL, APPROVED, or DENIED
private String status;
}
What does this code do?
@ManyToOne means many visit logs can reference the same visitor or homeowner. This entity is the heart of GateGuard. Every time a guard logs a visitor arrival, a new VisitLog row is created linking the visitor to the homeowner they are visiting.
Now create the four remaining entities. Each follows the same pattern you have already seen.
Create a new Java class named PriorAdvise in the entity package.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
@Entity
@Table(name = "prior_advises")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PriorAdvise {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The homeowner who pre-approved this visitor
@ManyToOne
@JoinColumn(name = "homeowner_id")
private Homeowner homeowner;
// Name of the expected visitor
private String visitorName;
// Date when the visitor is expected
private LocalDate date;
// Reason for the visit
private String purpose;
}
Create a new Java class named Complaint in the entity package.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "complaints")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Complaint {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The homeowner who submitted this complaint
@ManyToOne
@JoinColumn(name = "homeowner_id")
private Homeowner homeowner;
// Full text of the complaint or suggestion
@Column(columnDefinition = "TEXT")
private String description;
// Status: PENDING, REVIEWED, RESOLVED
private String status;
private LocalDateTime createdAt;
}
Create a new Java class named Payment in the entity package.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "payments")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The homeowner who made this payment
@ManyToOne
@JoinColumn(name = "homeowner_id")
private Homeowner homeowner;
// Payment amount in the local currency
private BigDecimal amount;
// Status: PENDING, COMPLETED, FAILED
private String status;
// Stripe Checkout Session ID for tracking the payment
private String stripeSessionId;
private LocalDateTime createdAt;
}
Create the final entity. Create a new Java class named Notification in the entity package.
- Replace the generated content with the following code:
package com.gateguard.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "notifications")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The user who should receive this notification
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
// The notification message content
private String message;
// Whether the user has seen this notification
@Column(name = "is_read")
private boolean read;
private LocalDateTime createdAt;
}
Why is the column named is_read?
"read" is a reserved word in some SQL contexts. Using @Column(name = "is_read") gives the database column an unambiguous name while keeping the Java field named simply read for cleaner code.
- Save all eight entity files.
You should now have eight files inside src/main/java/com/gateguard/entity/.
Seeing red underlines on Lombok annotations?
IntelliJ needs annotation processing enabled for Lombok to work. Go to Settings (Cmd+, on macOS), navigate to Build, Execution, Deployment then Compiler then Annotation Processors, and check Enable annotation processing.
help me fix Lombok annotations showing errors in IntelliJ IDEA Community Edition
Configure the application and verify the database tables
Spring Boot needs to know where your database lives and how to connect. You will replace the default application.properties with an application.yml file that is easier to read for complex configurations.
- In IntelliJ, expand src/main/resources.
- Delete the existing application.properties file (right-click, Delete).
- Right-click the resources folder, select New then File, and name it application.yml.
- Paste the following configuration into application.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/gateguard
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
# S3 configuration (to be filled during AWS deployment)
aws:
s3:
bucket-name: gateguard-images
region: us-east-1
# JWT configuration
jwt:
secret: gateguard-dev-secret-key-change-in-production
expiration: 86400000
What does this configuration do?
- spring.datasource tells Spring Boot how to connect to your PostgreSQL container running on port 5432.
- ddl-auto: update makes Hibernate automatically create or modify tables to match your entity classes every time the app starts.
- show-sql: true prints every SQL statement to the console so you can see what Hibernate does.
- The aws and jwt sections are placeholders you will use in later steps.
Never use ddl-auto: update in production
The update strategy is perfect for development because it creates tables without you writing SQL. In production, you would use a migration tool like Flyway or Liquibase for safe, version-controlled schema changes.
Before running the app, you need to temporarily disable Spring Security's default login page. It blocks startup verification.
- Create a new package under com.gateguard named config.
- Create a new Java class in the config package named SecurityConfig.
- Paste the following code:
package com.gateguard.config;
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.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
// Temporary: permit all requests so we can verify the app starts
// This will be replaced with JWT authentication in the next step
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
}
Why disable security now?
Spring Security locks down all endpoints by default. Without this temporary config, the app would redirect every request to a login page, making it impossible to verify Hibernate created your tables. You will replace this with proper JWT authentication in Step 4.
Now run the application to verify Hibernate creates the database tables.
- Make sure your PostgreSQL Docker container is running (you started it in a previous step).
- In IntelliJ, open the main application class at src/main/java/com/gateguard/GateguardApplication.java.
- Click the green Run icon in the gutter next to the main() method, or press Ctrl+Shift+F10 (macOS: Ctrl+Shift+R).
- Wait for the console to show Started GateguardApplication with no errors.
You should see Hibernate SQL statements in the console output creating tables like create table users, create table homeowners, and so on.
- Verify the tables exist by running this command in a new terminal:
docker exec -it $(docker ps -qf "ancestor=postgres") psql -U postgres -d gateguard -c "\dt"
You should see a table listing that includes users, homeowners, visitors, visit_logs, prior_advises, complaints, payments, and notifications.
App fails to start with a connection refused error?
This means your PostgreSQL container is not running. Check by running docker ps in the terminal. If you do not see a postgres container, restart it with the same docker run command you used in Step 1.
Also verify the username and password in application.yml match what you used when creating the container.
help me fix my Spring Boot app failing to connect to PostgreSQL running in Docker
Your database schema is in place and Hibernate is managing table creation. Next up, you will add JWT authentication and role-based access control to protect these endpoints.
Implement JWT Authentication and Authorization
Your database schema is in place and your Spring Boot application starts cleanly with all tables created. Now you need a way to control who can access what.
In this step, you will build a complete authentication system using JWT tokens and Spring Security. Guards, homeowners, and admins each get different permissions, and every API request must carry a valid token to prove identity.
In this step, get ready to:
- Build a JWT utility class for generating and validating tokens.
- Create registration and login endpoints with BCrypt password hashing.
- Configure Spring Security with role-based access control and test the full auth flow in Postman.
Add the JJWT dependency to your project
Before writing any authentication code, you need the JJWT library. This library handles the cryptographic signing and parsing of JSON Web Tokens.
- In IntelliJ, open your pom.xml file.
- Find the <dependencies> section and add the following three JJWT dependencies at the bottom, before the closing </dependencies> tag:
<!-- JWT token generation and validation -->
<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>
Why three separate dependencies?
JJWT splits its code into three modules. jjwt-api provides the classes you import in your code. jjwt-impl contains the actual token logic and stays at runtime scope so internal changes never break your compile. jjwt-jackson handles JSON serialization for the token payload.
- Save pom.xml.
- IntelliJ will show a floating Maven icon in the top-right corner of the editor. Click it to reload the project dependencies.
- In the Build tool window at the bottom, you should see Maven downloading the jjwt artifacts without errors.
Maven reload failing?
Make sure your <dependency> blocks are inside the <dependencies> section, not inside <dependencyManagement>. Also check that you have internet access for Maven Central downloads.
Help me fix my Maven dependency issue
Create the JWT utility class
The JWT utility class is the core of your authentication system. It generates tokens when users log in and validates them on every subsequent request.
- In IntelliJ's Project sidebar, right-click your main package (e.g. com.gateguard.api) under src/main/java.
- Select New then Package and name it security.
- Right-click the new security package, select New then Java Class, and name it JwtUtil.
- Paste the following code into JwtUtil.java:
package com.gateguard.api.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtUtil {
// Read the JWT secret from application.yml
@Value("${app.jwt.secret}")
private String secret;
// Token validity period: 24 hours in milliseconds
private static final long EXPIRATION_MS = 86400000;
// Generate a signed JWT containing the username and role
public String generateToken(String username, String role) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(key)
.compact();
}
// Extract the username (subject) from a token
public String extractUsername(String token) {
return getClaims(token).getSubject();
}
// Extract the role claim from a token
public String extractRole(String token) {
return getClaims(token).get("role", String.class);
}
// Validate that a token is properly signed and not expired
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
// Parse and verify the token, returning its claims
private Claims getClaims(String token) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
What does this code do?
generateToken() creates a signed token that embeds the username and role, set to expire in 24 hours.
validateToken() attempts to parse the token. If parsing succeeds, the signature is valid and the token is not expired. If it throws an exception, the token is rejected.
The secret key comes from application.yml via @Value. It must be at least 32 characters long for HMAC-SHA256 signing.
- Before this class will compile, confirm that your application.yml has a JWT secret property. Open src/main/resources/application.yml and verify you have this section:
app:
jwt:
secret: gateguard-secret-key-change-me-to-at-least-32-characters-long
- Save both JwtUtil.java and application.yml.
- The class should compile without errors in IntelliJ. Red underlines on the imports mean the Maven reload did not complete.
Seeing red underlines on JJWT imports?
Right-click pom.xml in the Project sidebar, then select Maven then Reload Project. Wait for the download progress bar to finish before checking imports again.
Help me fix unresolved JJWT imports in IntelliJ
Create the registration and login endpoints
With JwtUtil ready to mint tokens, you need two endpoints. One registers new users with a hashed password. The other validates credentials and returns a token.
- First, create a repository interface so the controller can query users. Right-click your main package, select New then Package, and name it repository.
- Inside the repository package, create a new Java interface named UserRepository with the following code:
package com.gateguard.api.repository;
import com.gateguard.api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Find a user by their username for login lookup
Optional<User> findByUsername(String username);
// Check if a username is already taken during registration
boolean existsByUsername(String username);
}
- Now create the controller package. Right-click your main package, select New then Package, and name it controller.
- Inside the controller package, create a new Java class named AuthController and paste the following code:
package com.gateguard.api.controller;
import com.gateguard.api.entity.User;
import com.gateguard.api.repository.UserRepository;
import com.gateguard.api.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
// Register a new user with a hashed password
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
String email = request.get("email");
String role = request.get("role");
// Prevent duplicate usernames
if (userRepository.existsByUsername(username)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Username already exists"));
}
// Create user with BCrypt-hashed password
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setEmail(email);
user.setRole(User.Role.valueOf(role.toUpperCase()));
userRepository.save(user);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered successfully"));
}
// Authenticate user and return a JWT token
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
// Look up user and verify password
User user = userRepository.findByUsername(username)
.orElse(null);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
}
// Generate JWT with username and role embedded
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
return ResponseEntity.ok(Map.of("token", token));
}
}
What does this code do?
The /api/auth/register endpoint accepts a username, password, email, and role. It hashes the password with BCrypt before saving, so raw passwords never touch the database.
The /api/auth/login endpoint checks the username exists and the password matches the stored hash. On success, it returns a signed JWT token that the client sends with every future request.
The PasswordEncoder bean does not exist yet. You will define it in the SecurityConfig class shortly.
- Save both UserRepository.java and AuthController.java.
- IntelliJ may show an error on PasswordEncoder because the bean is not yet defined. This is expected and resolves after the next substep.
Configure the Spring Security filter chain
Spring Security needs two things: a filter that reads the JWT from incoming requests, and a configuration that defines which endpoints each role can access.
- In the security package, create a new Java class named JwtAuthenticationFilter and paste this code:
package com.gateguard.api.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Look for the Authorization header with a Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.extractUsername(token);
String role = jwtUtil.extractRole(token);
// Set the authenticated user in Spring Security's context
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
username,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
// Continue the filter chain regardless
filterChain.doFilter(request, response);
}
}
What does this filter do?
Every HTTP request passes through this filter before reaching your controllers. It checks for an Authorization: Bearer <token> header.
If a valid token is found, the filter extracts the username and role, then sets the authentication in Spring Security's context. This is how downstream @PreAuthorize annotations and URL rules know who the current user is.
- Save JwtAuthenticationFilter.java.
- Now create the security configuration. In the security package, create a new Java class named SecurityConfig and paste this code:
package com.gateguard.api.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF since we use stateless JWT tokens
.csrf(csrf -> csrf.disable())
// No server-side sessions needed with JWT
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public: anyone can register or login
.requestMatchers("/api/auth/**").permitAll()
// Guard-only: visitor scanning and logging
.requestMatchers("/api/visitors/**").hasRole("GUARD")
.requestMatchers("/api/visit-logs/**").hasAnyRole("GUARD", "ADMIN")
// Homeowner: complaints and payments
.requestMatchers("/api/complaints/**").hasAnyRole("HOMEOWNER", "ADMIN")
.requestMatchers("/api/payments/**").hasAnyRole("HOMEOWNER", "ADMIN")
// Admin-only: management endpoints
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Everything else requires authentication
.anyRequest().authenticated()
)
// Insert JWT filter before Spring's default auth filter
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// BCrypt password encoder used by AuthController
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
What is role-based access control?
Each requestMatchers line maps a URL pattern to the roles allowed to access it. Guards can only hit visitor endpoints. Homeowners can only access complaints and payments. Admins can access everything.
The STATELESS session policy tells Spring Security not to create HTTP sessions. Every request authenticates itself via the JWT in the header, which is how modern APIs work.
The PasswordEncoder bean defined here is what AuthController injects to hash and verify passwords.
- Save SecurityConfig.java.
- Run your Spring Boot application from IntelliJ by clicking the green play button next to your main class. It should start without errors on port 8080.
Application fails to start?
If you see a circular dependency error, make sure JwtAuthenticationFilter does not inject UserRepository directly. It only needs JwtUtil.
If you see Could not resolve placeholder 'app.jwt.secret', check that your application.yml has the app.jwt.secret property and that indentation uses spaces, not tabs.
Help me fix my Spring Boot startup error
Test the auth flow with Postman
Your authentication system is built but untested. You will now verify that registration, login, and protected endpoint access all work correctly using Postman.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Postman and press Enter to open it.
- Create a new request by clicking the + tab at the top.
First, register a guard user.
- Set the method to POST and enter this URL:
- http://localhost:8080/api/auth/register.
- Click the Body tab, select raw, and set the dropdown to JSON.
- Paste this JSON body:
{
"username": "guard1",
"password": "password123",
"email": "guard1@gateguard.com",
"role": "GUARD"
}
- Click Send. You should see a 201 Created response with {"message": "User registered successfully"}.
Next, log in with the guard user to get a token.
- Create a new request tab.
- Set the method to POST and enter http://localhost:8080/api/auth/login.
- In the Body tab, select raw and JSON, then paste:
{
"username": "guard1",
"password": "password123"
}
- Click Send. You should see a 200 OK response with a token field containing a long encoded string.
- Copy the token value (the long string after "token":).
Now verify that the token grants access to a protected endpoint and that role restrictions work.
- Create a new request tab.
- Set the method to GET and enter http://localhost:8080/api/visitors.
- Click the Authorization tab, set the type to Bearer Token, and paste your copied token in the Token field.
- Click Send. You should not get a 403 Forbidden error. You may see 404 or an empty response since the visitor controller does not exist yet, but you should not see 401 or 403.
Finally, test that role restrictions prevent unauthorized access.
- With the same guard token, change the URL to http://localhost:8080/api/admin/test and click Send.
- You should see a 403 Forbidden response because the guard role is not allowed to access admin endpoints.
What should I see?
Register returns 201 with a success message. Login returns 200 with a JWT token string. Accessing guard endpoints with the guard token does not return 401 or 403. Accessing admin endpoints with the guard token returns 403.
Getting 403 on all endpoints including /api/auth/register?
Check that SecurityConfig has .requestMatchers("/api/auth/**").permitAll() before .anyRequest().authenticated(). The order of rules matters. Spring Security evaluates them top to bottom and uses the first match.
Also confirm that csrf is disabled. Without disabling CSRF, POST requests from Postman will be rejected because they do not include a CSRF token.
Help me debug my Spring Security 403 errors
✔️ Awesome, I've got everything!
Great. Double check you have saved all four new files: JwtUtil.java, UserRepository.java, AuthController.java, JwtAuthenticationFilter.java, and SecurityConfig.java.
ⓧ I'd like to double check the full code
Here is the complete code for each file you created in this step.
package com.gateguard.api.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${app.jwt.secret}")
private String secret;
private static final long EXPIRATION_MS = 86400000;
public String generateToken(String username, String role) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(key)
.compact();
}
public String extractUsername(String token) {
return getClaims(token).getSubject();
}
public String extractRole(String token) {
return getClaims(token).get("role", String.class);
}
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
private Claims getClaims(String token) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
package com.gateguard.api.repository;
import com.gateguard.api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}
package com.gateguard.api.controller;
import com.gateguard.api.entity.User;
import com.gateguard.api.repository.UserRepository;
import com.gateguard.api.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
String email = request.get("email");
String role = request.get("role");
if (userRepository.existsByUsername(username)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Username already exists"));
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setEmail(email);
user.setRole(User.Role.valueOf(role.toUpperCase()));
userRepository.save(user);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
User user = userRepository.findByUsername(username)
.orElse(null);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
}
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
return ResponseEntity.ok(Map.of("token", token));
}
}
package com.gateguard.api.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.extractUsername(token);
String role = jwtUtil.extractRole(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
username,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
package com.gateguard.api.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/visitors/**").hasRole("GUARD")
.requestMatchers("/api/visit-logs/**").hasAnyRole("GUARD", "ADMIN")
.requestMatchers("/api/complaints/**").hasAnyRole("HOMEOWNER", "ADMIN")
.requestMatchers("/api/payments/**").hasAnyRole("HOMEOWNER", "ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
spring:
datasource:
url: jdbc:postgresql://localhost:5432/gateguard
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
app:
jwt:
secret: gateguard-secret-key-change-me-to-at-least-32-characters-long
Your backend is now secured. Every future API endpoint you build will automatically be protected by JWT authentication and role-based rules. Next up, you will build the core homeowner and visitor management endpoints that guards and homeowners will actually use.
Build the Core Backend APIs
Your authentication layer is locked down. Guards, homeowners, and admins can register, log in, and receive role-specific access tokens.
Now you need the endpoints that actually DO something with those roles. A guard needs to search for homeowners, register visitors, and check them in. A homeowner needs to pre-approve expected visitors and view their visit history. In this step, you will build the full core API layer that powers the GateGuard visitor workflow.
In this step, get ready to:
- Build homeowner CRUD endpoints with flexible search using JPA Specification.
- Create visitor, visit log, and prior advise endpoints for the gate workflow.
- Implement the visitor check-in endpoint that automates approval decisions.
Create the homeowner repository and search specification
Guards need to find a homeowner quickly when a visitor arrives at the gate. They might search by block number, lot number, phase, or the homeowner's name. JPA Specification lets you combine these search criteria dynamically so guards can use any combination of filters.
- Create a new file HomeownerRepository.java in your repository package and add the following code:
package com.gateguard.repository;
import com.gateguard.entity.Homeowner;
import com.gateguard.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Optional;
// Extends JpaSpecificationExecutor to support dynamic search queries
public interface HomeownerRepository extends JpaRepository<Homeowner, Long>, JpaSpecificationExecutor<Homeowner> {
Optional<Homeowner> findByUser(User user);
}
What does JpaSpecificationExecutor give us?
By extending JpaSpecificationExecutor, your repository gains a findAll(Specification) method. This lets you chain multiple filter conditions at runtime without writing custom SQL for each combination.
- Create a new package called specification inside your main source package.
- Create HomeownerSpecification.java inside the specification package with the following code:
package com.gateguard.specification;
import com.gateguard.entity.Homeowner;
import org.springframework.data.jpa.domain.Specification;
public class HomeownerSpecification {
// Filter by block number (exact match)
public static Specification<Homeowner> hasBlock(String blk) {
return (root, query, cb) -> blk == null ? null : cb.equal(root.get("blk"), blk);
}
// Filter by lot number (exact match)
public static Specification<Homeowner> hasLot(String lot) {
return (root, query, cb) -> lot == null ? null : cb.equal(root.get("lot"), lot);
}
// Filter by phase (exact match)
public static Specification<Homeowner> hasPhase(String phase) {
return (root, query, cb) -> phase == null ? null : cb.equal(root.get("phase"), phase);
}
// Filter by name (case-insensitive partial match)
public static Specification<Homeowner> fullNameContains(String name) {
return (root, query, cb) -> name == null ? null :
cb.like(cb.lower(root.get("fullName")), "%" + name.toLowerCase() + "%");
}
}
What does this code do?
Each static method returns a Specification that becomes one WHERE clause in the final SQL query. When a parameter is null, that filter is skipped entirely.
The fullNameContains method uses a case-insensitive LIKE query so guards can search partial names without worrying about capitalization.
- Save both files.
Seeing import errors?
Make sure your pom.xml includes the spring-boot-starter-data-jpa dependency. The JpaSpecificationExecutor interface comes from Spring Data JPA, which you added during project setup.
Help me fix my JPA Specification imports
Build the HomeownerController
The controller exposes CRUD endpoints plus the flexible search. Guards and admins use the search endpoint. Only admins can list all homeowners or delete records.
- Create HomeownerController.java in your controller package with the following code:
package com.gateguard.controller;
import com.gateguard.entity.Homeowner;
import com.gateguard.repository.HomeownerRepository;
import com.gateguard.specification.HomeownerSpecification;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/homeowners")
@RequiredArgsConstructor
public class HomeownerController {
private final HomeownerRepository homeownerRepository;
// Create a new homeowner profile
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> createHomeowner(@RequestBody Homeowner homeowner) {
Homeowner saved = homeownerRepository.save(homeowner);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
// Get all homeowners (admin only)
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<Homeowner>> getAllHomeowners() {
return ResponseEntity.ok(homeownerRepository.findAll());
}
// Get a single homeowner by ID
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('GUARD') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> getHomeowner(@PathVariable Long id) {
return homeownerRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// Update homeowner profile
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> updateHomeowner(@PathVariable Long id, @RequestBody Homeowner updated) {
return homeownerRepository.findById(id)
.map(existing -> {
existing.setBlk(updated.getBlk());
existing.setLot(updated.getLot());
existing.setPhase(updated.getPhase());
existing.setFullName(updated.getFullName());
existing.setContactInfo(updated.getContactInfo());
return ResponseEntity.ok(homeownerRepository.save(existing));
})
.orElse(ResponseEntity.notFound().build());
}
// Delete homeowner (admin only)
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteHomeowner(@PathVariable Long id) {
homeownerRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// Search homeowners by blk, lot, phase, or full name
@GetMapping("/search")
@PreAuthorize("hasRole('GUARD') or hasRole('ADMIN')")
public ResponseEntity<List<Homeowner>> searchHomeowners(
@RequestParam(required = false) String blk,
@RequestParam(required = false) String lot,
@RequestParam(required = false) String phase,
@RequestParam(required = false) String fullName) {
Specification<Homeowner> spec = Specification
.where(HomeownerSpecification.hasBlock(blk))
.and(HomeownerSpecification.hasLot(lot))
.and(HomeownerSpecification.hasPhase(phase))
.and(HomeownerSpecification.fullNameContains(fullName));
return ResponseEntity.ok(homeownerRepository.findAll(spec));
}
}
What does this code do?
The @PreAuthorize annotations enforce role-based access. Guards can search but not delete. Admins can do everything. Homeowners can create and update their own profiles.
The /search endpoint chains all four specifications with .and(). If a query parameter is missing, that specification returns null and gets ignored. This means a guard can search by just block number, just name, or any combination.
- Save HomeownerController.java.
- Restart your Spring Boot application in IntelliJ.
- Open Postman and send a POST request to http://localhost:8080/api/auth/login with your admin credentials to get a fresh JWT token.
- Send a POST request to http://localhost:8080/api/homeowners with the JWT in the Authorization header as Bearer <your-token> and the following JSON body:
{
"blk": "5",
"lot": "12",
"phase": "2",
"fullName": "Maria Santos",
"contactInfo": "09171234567"
}
- You should receive a 201 Created response with the saved homeowner data including a generated id.
- Test the search endpoint by sending a GET request to http://localhost:8080/api/homeowners/search?blk=5 with your guard user's token.
- You should see the homeowner you just created returned in the search results.
Getting 403 Forbidden?
Check that the user whose token you are using has the correct role. The search endpoint requires GUARD or ADMIN role. Make sure your SecurityConfig has @EnableMethodSecurity annotation to activate @PreAuthorize.
Help me debug my 403 Forbidden error on the homeowner search endpoint
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files: HomeownerRepository.java, HomeownerSpecification.java, and HomeownerController.java.
ⓧ I'd like to double check the full code
package com.gateguard.repository;
import com.gateguard.entity.Homeowner;
import com.gateguard.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Optional;
public interface HomeownerRepository extends JpaRepository<Homeowner, Long>, JpaSpecificationExecutor<Homeowner> {
Optional<Homeowner> findByUser(User user);
}
package com.gateguard.specification;
import com.gateguard.entity.Homeowner;
import org.springframework.data.jpa.domain.Specification;
public class HomeownerSpecification {
public static Specification<Homeowner> hasBlock(String blk) {
return (root, query, cb) -> blk == null ? null : cb.equal(root.get("blk"), blk);
}
public static Specification<Homeowner> hasLot(String lot) {
return (root, query, cb) -> lot == null ? null : cb.equal(root.get("lot"), lot);
}
public static Specification<Homeowner> hasPhase(String phase) {
return (root, query, cb) -> phase == null ? null : cb.equal(root.get("phase"), phase);
}
public static Specification<Homeowner> fullNameContains(String name) {
return (root, query, cb) -> name == null ? null :
cb.like(cb.lower(root.get("fullName")), "%" + name.toLowerCase() + "%");
}
}
package com.gateguard.controller;
import com.gateguard.entity.Homeowner;
import com.gateguard.repository.HomeownerRepository;
import com.gateguard.specification.HomeownerSpecification;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/homeowners")
@RequiredArgsConstructor
public class HomeownerController {
private final HomeownerRepository homeownerRepository;
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> createHomeowner(@RequestBody Homeowner homeowner) {
Homeowner saved = homeownerRepository.save(homeowner);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<Homeowner>> getAllHomeowners() {
return ResponseEntity.ok(homeownerRepository.findAll());
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('GUARD') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> getHomeowner(@PathVariable Long id) {
return homeownerRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('HOMEOWNER')")
public ResponseEntity<Homeowner> updateHomeowner(@PathVariable Long id, @RequestBody Homeowner updated) {
return homeownerRepository.findById(id)
.map(existing -> {
existing.setBlk(updated.getBlk());
existing.setLot(updated.getLot());
existing.setPhase(updated.getPhase());
existing.setFullName(updated.getFullName());
existing.setContactInfo(updated.getContactInfo());
return ResponseEntity.ok(homeownerRepository.save(existing));
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteHomeowner(@PathVariable Long id) {
homeownerRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
@PreAuthorize("hasRole('GUARD') or hasRole('ADMIN')")
public ResponseEntity<List<Homeowner>> searchHomeowners(
@RequestParam(required = false) String blk,
@RequestParam(required = false) String lot,
@RequestParam(required = false) String phase,
@RequestParam(required = false) String fullName) {
Specification<Homeowner> spec = Specification
.where(HomeownerSpecification.hasBlock(blk))
.and(HomeownerSpecification.hasLot(lot))
.and(HomeownerSpecification.hasPhase(phase))
.and(HomeownerSpecification.fullNameContains(fullName));
return ResponseEntity.ok(homeownerRepository.findAll(spec));
}
}
Build the visitor and visit log endpoints
When a visitor arrives at the gate, the guard captures their information and photos. The system needs to store visitor records and log each visit linked to a specific homeowner. Homeowners also need a way to pre-approve visitors so the gate system can auto-approve them.
- Create VisitorRepository.java in your repository package with the following code:
package com.gateguard.repository;
import com.gateguard.entity.Visitor;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VisitorRepository extends JpaRepository<Visitor, Long> {
}
- Create VisitorController.java in your controller package with the following code:
package com.gateguard.controller;
import com.gateguard.entity.Visitor;
import com.gateguard.repository.VisitorRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/visitors")
@RequiredArgsConstructor
public class VisitorController {
private final VisitorRepository visitorRepository;
// Create a new visitor record with photo URLs and OCR data
@PostMapping
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<Visitor> createVisitor(@RequestBody Visitor visitor) {
Visitor saved = visitorRepository.save(visitor);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
// Get visitor by ID
@GetMapping("/{id}")
@PreAuthorize("hasRole('GUARD') or hasRole('ADMIN')")
public ResponseEntity<Visitor> getVisitor(@PathVariable Long id) {
return visitorRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
What does this code do?
The VisitorController accepts visitor data including idPhotoUrl, livePhotoUrl, and ocrData fields. The photo URLs will be populated later when you integrate S3 storage. For now, you can pass placeholder strings.
- Save both files.
Next, you need the visit log to track every time a visitor enters the subdivision and which homeowner they are visiting.
- Create VisitLogRepository.java in your repository package with the following code:
package com.gateguard.repository;
import com.gateguard.entity.VisitLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Custom query method: Spring Data JPA auto-generates the SQL from the method name
public interface VisitLogRepository extends JpaRepository<VisitLog, Long> {
List<VisitLog> findByHomeownerId(Long homeownerId);
}
- Create VisitLogController.java in your controller package with the following code:
package com.gateguard.controller;
import com.gateguard.entity.VisitLog;
import com.gateguard.repository.VisitLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/api/visit-logs")
@RequiredArgsConstructor
public class VisitLogController {
private final VisitLogRepository visitLogRepository;
// Log a new visit (guard creates this when a visitor checks in)
@PostMapping
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<VisitLog> createVisitLog(@RequestBody VisitLog visitLog) {
visitLog.setTimestamp(LocalDateTime.now());
VisitLog saved = visitLogRepository.save(visitLog);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
// Get visit history for a specific homeowner
@GetMapping("/homeowner/{homeownerId}")
@PreAuthorize("hasRole('HOMEOWNER') or hasRole('ADMIN')")
public ResponseEntity<List<VisitLog>> getVisitsByHomeowner(@PathVariable Long homeownerId) {
List<VisitLog> logs = visitLogRepository.findByHomeownerId(homeownerId);
return ResponseEntity.ok(logs);
}
}
What does this code do?
The createVisitLog method automatically sets the current timestamp. Guards provide the visitor ID, homeowner ID, and purpose. Homeowners can retrieve their visit history to see who has visited them.
- Save both files.
The final piece is the prior advise system. Homeowners pre-register visitors they expect, so the guard can approve them immediately without calling the homeowner.
- Create PriorAdviseRepository.java in your repository package with the following code:
package com.gateguard.repository;
import com.gateguard.entity.PriorAdvise;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PriorAdviseRepository extends JpaRepository<PriorAdvise, Long> {
// Find all pre-approvals for a homeowner
List<PriorAdvise> findByHomeownerId(Long homeownerId);
// Check if a specific visitor was pre-approved by a homeowner
List<PriorAdvise> findByHomeownerIdAndVisitorName(Long homeownerId, String visitorName);
}
- Create PriorAdviseController.java in your controller package with the following code:
package com.gateguard.controller;
import com.gateguard.entity.PriorAdvise;
import com.gateguard.repository.PriorAdviseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/prior-advises")
@RequiredArgsConstructor
public class PriorAdviseController {
private final PriorAdviseRepository priorAdviseRepository;
// Homeowner pre-approves an expected visitor
@PostMapping
@PreAuthorize("hasRole('HOMEOWNER')")
public ResponseEntity<PriorAdvise> createPriorAdvise(@RequestBody PriorAdvise priorAdvise) {
PriorAdvise saved = priorAdviseRepository.save(priorAdvise);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
// Get all pre-approvals for a homeowner
@GetMapping("/homeowner/{homeownerId}")
@PreAuthorize("hasRole('HOMEOWNER') or hasRole('ADMIN')")
public ResponseEntity<List<PriorAdvise>> getByHomeowner(@PathVariable Long homeownerId) {
return ResponseEntity.ok(priorAdviseRepository.findByHomeownerId(homeownerId));
}
}
What does this code do?
Homeowners call POST /api/prior-advises to register someone they expect. They provide the visitor's name, expected date, and purpose. The check-in endpoint (built in the next substep) will look up these records to auto-approve matching visitors.
- Save both files.
- Restart your Spring Boot application.
- In Postman, log in as a homeowner user and send a POST to http://localhost:8080/api/prior-advises with this body:
{
"homeowner": { "id": 1 },
"visitorName": "Juan Dela Cruz",
"date": "2026-05-15",
"purpose": "Delivery"
}
- You should receive a 201 Created response with the saved prior advise record.
Getting a 500 Internal Server Error?
Check your IntelliJ console for the full stack trace. A common cause is a missing or invalid homeowner ID. Make sure homeowner ID 1 exists in your database. You created one in the previous testing step.
Help me debug my 500 error when creating a prior advise record
✔️ Awesome, I've got everything!
Great. You now have visitor, visit log, and prior advise endpoints ready.
ⓧ I'd like to double check the full code
package com.gateguard.repository;
import com.gateguard.entity.Visitor;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VisitorRepository extends JpaRepository<Visitor, Long> {
}
package com.gateguard.controller;
import com.gateguard.entity.Visitor;
import com.gateguard.repository.VisitorRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/visitors")
@RequiredArgsConstructor
public class VisitorController {
private final VisitorRepository visitorRepository;
@PostMapping
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<Visitor> createVisitor(@RequestBody Visitor visitor) {
Visitor saved = visitorRepository.save(visitor);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('GUARD') or hasRole('ADMIN')")
public ResponseEntity<Visitor> getVisitor(@PathVariable Long id) {
return visitorRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
package com.gateguard.repository;
import com.gateguard.entity.VisitLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface VisitLogRepository extends JpaRepository<VisitLog, Long> {
List<VisitLog> findByHomeownerId(Long homeownerId);
}
package com.gateguard.controller;
import com.gateguard.entity.VisitLog;
import com.gateguard.repository.VisitLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequestMapping("/api/visit-logs")
@RequiredArgsConstructor
public class VisitLogController {
private final VisitLogRepository visitLogRepository;
@PostMapping
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<VisitLog> createVisitLog(@RequestBody VisitLog visitLog) {
visitLog.setTimestamp(LocalDateTime.now());
VisitLog saved = visitLogRepository.save(visitLog);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping("/homeowner/{homeownerId}")
@PreAuthorize("hasRole('HOMEOWNER') or hasRole('ADMIN')")
public ResponseEntity<List<VisitLog>> getVisitsByHomeowner(@PathVariable Long homeownerId) {
List<VisitLog> logs = visitLogRepository.findByHomeownerId(homeownerId);
return ResponseEntity.ok(logs);
}
}
package com.gateguard.repository;
import com.gateguard.entity.PriorAdvise;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PriorAdviseRepository extends JpaRepository<PriorAdvise, Long> {
List<PriorAdvise> findByHomeownerId(Long homeownerId);
List<PriorAdvise> findByHomeownerIdAndVisitorName(Long homeownerId, String visitorName);
}
package com.gateguard.controller;
import com.gateguard.entity.PriorAdvise;
import com.gateguard.repository.PriorAdviseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/prior-advises")
@RequiredArgsConstructor
public class PriorAdviseController {
private final PriorAdviseRepository priorAdviseRepository;
@PostMapping
@PreAuthorize("hasRole('HOMEOWNER')")
public ResponseEntity<PriorAdvise> createPriorAdvise(@RequestBody PriorAdvise priorAdvise) {
PriorAdvise saved = priorAdviseRepository.save(priorAdvise);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping("/homeowner/{homeownerId}")
@PreAuthorize("hasRole('HOMEOWNER') or hasRole('ADMIN')")
public ResponseEntity<List<PriorAdvise>> getByHomeowner(@PathVariable Long homeownerId) {
return ResponseEntity.ok(priorAdviseRepository.findByHomeownerId(homeownerId));
}
}
Implement the visitor check-in workflow
The individual endpoints work, but the real value comes from combining them into a single workflow. When a guard checks in a visitor, the system should automatically decide whether to approve or hold the visit based on the homeowner's prior advise records.
- Create VisitCheckInController.java in your controller package with the following code:
package com.gateguard.controller;
import com.gateguard.entity.*;
import com.gateguard.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/visits")
@RequiredArgsConstructor
public class VisitCheckInController {
private final VisitorRepository visitorRepository;
private final HomeownerRepository homeownerRepository;
private final PriorAdviseRepository priorAdviseRepository;
private final VisitLogRepository visitLogRepository;
// Full check-in workflow: creates visitor, checks prior advise, logs visit
@PostMapping("/check-in")
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<Map<String, Object>> checkIn(@RequestBody Map<String, Object> request) {
String visitorName = (String) request.get("visitorName");
Long targetHomeownerId = Long.valueOf(request.get("targetHomeownerId").toString());
String purpose = (String) request.get("purpose");
String idPhotoUrl = (String) request.get("idPhotoUrl");
String livePhotoUrl = (String) request.get("livePhotoUrl");
// Step 1: Find the target homeowner
Homeowner homeowner = homeownerRepository.findById(targetHomeownerId)
.orElse(null);
if (homeowner == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Homeowner not found"));
}
// Step 2: Create the visitor record
Visitor visitor = new Visitor();
visitor.setName(visitorName);
visitor.setIdPhotoUrl(idPhotoUrl);
visitor.setLivePhotoUrl(livePhotoUrl);
Visitor savedVisitor = visitorRepository.save(visitor);
// Step 3: Check if homeowner has a prior advise for this visitor
List<PriorAdvise> advises = priorAdviseRepository
.findByHomeownerIdAndVisitorName(targetHomeownerId, visitorName);
// Auto-approve if prior advise exists, otherwise hold for approval
String status = advises.isEmpty() ? "PENDING_APPROVAL" : "APPROVED";
// Step 4: Create the visit log with the determined status
VisitLog visitLog = new VisitLog();
visitLog.setVisitor(savedVisitor);
visitLog.setHomeowner(homeowner);
visitLog.setPurpose(purpose);
visitLog.setTimestamp(LocalDateTime.now());
visitLog.setStatus(status);
visitLogRepository.save(visitLog);
// Return the result to the guard
return ResponseEntity.ok(Map.of(
"status", status,
"visitorId", savedVisitor.getId(),
"message", status.equals("APPROVED")
? "Visitor pre-approved by homeowner"
: "Awaiting homeowner approval"
));
}
}
What does this code do?
This endpoint orchestrates the entire visitor entry workflow in one request. The guard sends the visitor's name, the target homeowner's ID, the visit purpose, and photo URLs.
- It validates that the homeowner exists.
- It creates a visitor record in the database.
- It checks PriorAdvise records for a match. If the homeowner pre-approved this visitor name, the status is APPROVED. Otherwise it is PENDING_APPROVAL.
- It logs the visit with the determined status.
- Save VisitCheckInController.java.
- Restart your Spring Boot application.
- In Postman, log in as a guard user and send a POST request to http://localhost:8080/api/visits/check-in with this body (using the pre-approved visitor name):
{
"visitorName": "Juan Dela Cruz",
"targetHomeownerId": 1,
"purpose": "Delivery",
"idPhotoUrl": "https://placeholder.com/id-photo.jpg",
"livePhotoUrl": "https://placeholder.com/live-photo.jpg"
}
- You should receive a 200 OK response with "status": "APPROVED" and the message "Visitor pre-approved by homeowner" because you created a prior advise for this visitor name earlier.
- Now test with a visitor name that has no prior advise. Change visitorName to "Unknown Visitor" and send the request again.
- This time you should see "status": "PENDING_APPROVAL" and the message "Awaiting homeowner approval".
Getting an error instead of approval status?
Make sure your VisitLog entity has a status field of type String. Also verify that the targetHomeownerId in your request body matches an existing homeowner record in your database.
If you see a NullPointerException, check that all four repositories (VisitorRepository, HomeownerRepository, PriorAdviseRepository, VisitLogRepository) are being injected correctly via the @RequiredArgsConstructor annotation.
Help me debug my check-in endpoint error
✔️ Awesome, I've got everything!
Great. Your check-in workflow is complete. Make sure you have saved all files.
ⓧ I'd like to double check the full code
package com.gateguard.controller;
import com.gateguard.entity.*;
import com.gateguard.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/visits")
@RequiredArgsConstructor
public class VisitCheckInController {
private final VisitorRepository visitorRepository;
private final HomeownerRepository homeownerRepository;
private final PriorAdviseRepository priorAdviseRepository;
private final VisitLogRepository visitLogRepository;
@PostMapping("/check-in")
@PreAuthorize("hasRole('GUARD')")
public ResponseEntity<Map<String, Object>> checkIn(@RequestBody Map<String, Object> request) {
String visitorName = (String) request.get("visitorName");
Long targetHomeownerId = Long.valueOf(request.get("targetHomeownerId").toString());
String purpose = (String) request.get("purpose");
String idPhotoUrl = (String) request.get("idPhotoUrl");
String livePhotoUrl = (String) request.get("livePhotoUrl");
Homeowner homeowner = homeownerRepository.findById(targetHomeownerId)
.orElse(null);
if (homeowner == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Homeowner not found"));
}
Visitor visitor = new Visitor();
visitor.setName(visitorName);
visitor.setIdPhotoUrl(idPhotoUrl);
visitor.setLivePhotoUrl(livePhotoUrl);
Visitor savedVisitor = visitorRepository.save(visitor);
List<PriorAdvise> advises = priorAdviseRepository
.findByHomeownerIdAndVisitorName(targetHomeownerId, visitorName);
String status = advises.isEmpty() ? "PENDING_APPROVAL" : "APPROVED";
VisitLog visitLog = new VisitLog();
visitLog.setVisitor(savedVisitor);
visitLog.setHomeowner(homeowner);
visitLog.setPurpose(purpose);
visitLog.setTimestamp(LocalDateTime.now());
visitLog.setStatus(status);
visitLogRepository.save(visitLog);
return ResponseEntity.ok(Map.of(
"status", status,
"visitorId", savedVisitor.getId(),
"message", status.equals("APPROVED")
? "Visitor pre-approved by homeowner"
: "Awaiting homeowner approval"
));
}
}
Why use a single check-in endpoint instead of multiple calls?
The mobile app could call each endpoint separately (create visitor, check prior advise, create visit log). Combining them into one endpoint reduces network round-trips from the guard's phone and ensures all steps happen atomically. If any step fails, you can handle it in one place.
Your core API layer is complete. Guards can search homeowners, register visitors, and check them in with automatic approval decisions. Homeowners can pre-approve visitors and view their visit history. Next up, you will build the Python OCR microservice that extracts text from visitor ID photos automatically.
Build the PaddleOCR Microservice
Your Spring Boot backend can log visitors and check approvals, but guards still need to manually type in ID details. That is slow and error-prone at a busy gate.
In this step, you will build a standalone Python microservice that uses PaddleOCR to extract text from ID photos automatically. Then you will wire your Spring Boot app to call this microservice whenever a visitor is created.
In this step, get ready to:
- Create the FastAPI OCR microservice with project structure and extraction endpoint.
- Add document type detection logic to parse ID fields from raw OCR text.
- Connect Spring Boot to the OCR microservice with an OcrClient service.
Create the FastAPI project structure
The OCR microservice runs as its own process, completely separate from your Java backend. This is the microservices pattern in action.
The Java app sends HTTP requests to this Python service just like it would to any external API.
Why a separate microservice for OCR?
PaddleOCR is a Python library with heavy native dependencies. Running it inside the Java process would be impractical.
Keeping OCR as a separate service means you can scale it independently, restart it without affecting the main API, and swap OCR engines in the future without touching Java code.
- Navigate to your project root (the parent folder that contains your Spring Boot project) by running:
cd ~/Desktop/gateguard
- Create the OCR service directory structure by running these commands:
mkdir -p ocr-service/routers ocr-service/services
touch ocr-service/main.py ocr-service/routers/__init__.py ocr-service/routers/ocr.py ocr-service/services/__init__.py ocr-service/services/ocr_service.py
- Confirm the files exist by running:
ls ocr-service ocr-service/routers ocr-service/services
You should see main.py in the ocr-service folder, ocr.py and __init__.py in routers, and ocr_service.py and __init__.py in services.
Don't see all the files?
- Make sure you ran both the mkdir -p and touch commands. The -p flag creates parent directories if they do not exist yet.
- If you accidentally created files in the wrong location, delete them and re-run from the ~/Desktop/gateguard directory.
help me fix my ocr-service folder structure
Now open ocr-service/main.py in VS Code and add the FastAPI application entrypoint.
- Paste the following code into ocr-service/main.py:
from fastapi import FastAPI
from routers import ocr
# Create the FastAPI application instance
app = FastAPI(title="GateGuard OCR Service")
# Register the OCR router with URL prefix
app.include_router(ocr.router, prefix="/api/ocr", tags=["OCR"])
if __name__ == "__main__":
import uvicorn
# Run on port 8001 to avoid conflict with Spring Boot on 8080
uvicorn.run(app, host="0.0.0.0", port=8001)
What does this code do?
- Creates a FastAPI app with the title "GateGuard OCR Service".
- Mounts the OCR router at /api/ocr so the extract endpoint will live at /api/ocr/extract.
- Runs on port 8001 to stay clear of Spring Boot's port 8080.
- Save main.py.
Build the OCR extraction endpoint and parsing logic
The router defines the HTTP endpoint that accepts image data. The service layer handles the actual OCR processing and field parsing.
- Paste the following code into ocr-service/routers/ocr.py:
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.ocr_service import extract_text, parse_id_fields
router = APIRouter()
# Request model: expects a base64-encoded image string
class OcrRequest(BaseModel):
imageBase64: str
# Structured fields parsed from the ID document
class ParsedFields(BaseModel):
name: str | None = None
idNumber: str | None = None
address: str | None = None
dateOfBirth: str | None = None
# Response model with raw text and parsed fields
class OcrResponse(BaseModel):
rawText: str
parsedFields: ParsedFields
@router.post("/extract", response_model=OcrResponse)
async def extract_text_from_image(request: OcrRequest):
"""Accept a base64 image, run OCR, return extracted text and parsed fields."""
try:
# Extract raw text from the image
raw_text = extract_text(request.imageBase64)
# Attempt to parse structured ID fields
parsed_fields = parse_id_fields(raw_text)
return OcrResponse(
rawText=raw_text,
parsedFields=ParsedFields(**parsed_fields)
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
What does this code do?
- Defines a POST endpoint at /extract that accepts a JSON body with an imageBase64 field.
- Calls extract_text() to run PaddleOCR on the decoded image.
- Calls parse_id_fields() to attempt structured parsing of the raw OCR output.
- Returns a JSON response with rawText and parsedFields (name, idNumber, address, dateOfBirth).
- Save ocr.py.
Now build the core OCR service that handles image decoding, text extraction, and field parsing.
- Paste the following code into ocr-service/services/ocr_service.py:
import base64
import tempfile
import re
from paddleocr import PaddleOCR
# Initialize PaddleOCR with English language model
# Models download automatically on first run
ocr = PaddleOCR(
lang="en",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False
)
def extract_text(base64_image: str) -> str:
"""Decode a base64 image, run PaddleOCR, return all recognized text."""
# Decode the base64 string into raw image bytes
image_bytes = base64.b64decode(base64_image)
# Write to a temporary file (PaddleOCR accepts file paths)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(image_bytes)
tmp_path = tmp.name
# Run OCR prediction on the image
result = ocr.predict(tmp_path)
# Collect all recognized text strings from the result
texts = []
for res in result:
if "rec_texts" in res:
texts.extend(res["rec_texts"])
return "\n".join(texts)
def parse_id_fields(raw_text: str) -> dict:
"""Attempt to parse common ID document fields from raw OCR text."""
fields = {
"name": None,
"idNumber": None,
"address": None,
"dateOfBirth": None
}
lines = raw_text.split("\n")
for line in lines:
# Match ID number patterns (digits with optional dashes)
id_match = re.search(r"\b(\d{4}[-\s]?\d{4}[-\s]?\d{4})\b", line)
if id_match and not fields["idNumber"]:
fields["idNumber"] = id_match.group(1)
continue
# Match date patterns (DD/MM/YYYY or MM-DD-YYYY variants)
dob_match = re.search(r"\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b", line)
if dob_match and not fields["dateOfBirth"]:
fields["dateOfBirth"] = dob_match.group(1)
continue
# Match address lines containing common keywords
address_keywords = ["street", "st", "ave", "blvd", "road", "rd", "city", "brgy"]
if any(kw in line.lower() for kw in address_keywords) and not fields["address"]:
fields["address"] = line.strip()
continue
# Match name fields with explicit labels
name_match = re.search(r"(?:name|nama)[:\s]+(.+)", line, re.IGNORECASE)
if name_match and not fields["name"]:
fields["name"] = name_match.group(1).strip()
continue
# Fallback: use first line that looks like a proper name
if not fields["name"]:
for line in lines:
if line.strip() and re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+", line.strip()):
fields["name"] = line.strip()
break
return fields
What does this code do?
- Initializes PaddleOCR once at module load with the English language model. Models download automatically the first time you run the service.
- The extract_text() function decodes the base64 image to a temp file, runs ocr.predict(), and collects all recognized strings from rec_texts in the result.
- The parse_id_fields() function uses regex patterns to identify ID numbers (12-digit sequences), dates, addresses (by keyword), and names (by label or capitalization patterns).
- Save ocr_service.py.
- Activate your Python virtual environment and start the OCR service by running:
cd ~/Desktop/gateguard/ocr-service
source ../venv/bin/activate
python main.py
You should see uvicorn output showing the server started on http://0.0.0.0:8001. The first run downloads PaddleOCR models, which may take a minute.
Server not starting?
- If you see ModuleNotFoundError: No module named 'paddleocr', make sure your virtual environment is activated. Run source ../venv/bin/activate first.
- If port 8001 is already in use, check what is running on that port with lsof -i :8001.
- If you see import errors from routers or services, confirm you are running python main.py from inside the ocr-service directory.
help me debug why my FastAPI OCR service won't start
- Open a new terminal tab and test the endpoint by sending a tiny 1x1 white PNG encoded as base64:
curl -X POST http://localhost:8001/api/ocr/extract \
-H "Content-Type: application/json" \
-d '{"imageBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="}'
You should see a JSON response with rawText (likely empty for a 1x1 image) and parsedFields with null values. This confirms the endpoint is working correctly.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files in the ocr-service directory.
ⓧ I'd like to double check the full code
Here is the complete code for all three OCR service files:
from fastapi import FastAPI
from routers import ocr
# Create the FastAPI application instance
app = FastAPI(title="GateGuard OCR Service")
# Register the OCR router with URL prefix
app.include_router(ocr.router, prefix="/api/ocr", tags=["OCR"])
if __name__ == "__main__":
import uvicorn
# Run on port 8001 to avoid conflict with Spring Boot on 8080
uvicorn.run(app, host="0.0.0.0", port=8001)
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.ocr_service import extract_text, parse_id_fields
router = APIRouter()
# Request model: expects a base64-encoded image string
class OcrRequest(BaseModel):
imageBase64: str
# Structured fields parsed from the ID document
class ParsedFields(BaseModel):
name: str | None = None
idNumber: str | None = None
address: str | None = None
dateOfBirth: str | None = None
# Response model with raw text and parsed fields
class OcrResponse(BaseModel):
rawText: str
parsedFields: ParsedFields
@router.post("/extract", response_model=OcrResponse)
async def extract_text_from_image(request: OcrRequest):
"""Accept a base64 image, run OCR, return extracted text and parsed fields."""
try:
# Extract raw text from the image
raw_text = extract_text(request.imageBase64)
# Attempt to parse structured ID fields
parsed_fields = parse_id_fields(raw_text)
return OcrResponse(
rawText=raw_text,
parsedFields=ParsedFields(**parsed_fields)
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
import base64
import tempfile
import re
from paddleocr import PaddleOCR
# Initialize PaddleOCR with English language model
# Models download automatically on first run
ocr = PaddleOCR(
lang="en",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False
)
def extract_text(base64_image: str) -> str:
"""Decode a base64 image, run PaddleOCR, return all recognized text."""
# Decode the base64 string into raw image bytes
image_bytes = base64.b64decode(base64_image)
# Write to a temporary file (PaddleOCR accepts file paths)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(image_bytes)
tmp_path = tmp.name
# Run OCR prediction on the image
result = ocr.predict(tmp_path)
# Collect all recognized text strings from the result
texts = []
for res in result:
if "rec_texts" in res:
texts.extend(res["rec_texts"])
return "\n".join(texts)
def parse_id_fields(raw_text: str) -> dict:
"""Attempt to parse common ID document fields from raw OCR text."""
fields = {
"name": None,
"idNumber": None,
"address": None,
"dateOfBirth": None
}
lines = raw_text.split("\n")
for line in lines:
# Match ID number patterns (digits with optional dashes)
id_match = re.search(r"\b(\d{4}[-\s]?\d{4}[-\s]?\d{4})\b", line)
if id_match and not fields["idNumber"]:
fields["idNumber"] = id_match.group(1)
continue
# Match date patterns (DD/MM/YYYY or MM-DD-YYYY variants)
dob_match = re.search(r"\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b", line)
if dob_match and not fields["dateOfBirth"]:
fields["dateOfBirth"] = dob_match.group(1)
continue
# Match address lines containing common keywords
address_keywords = ["street", "st", "ave", "blvd", "road", "rd", "city", "brgy"]
if any(kw in line.lower() for kw in address_keywords) and not fields["address"]:
fields["address"] = line.strip()
continue
# Match name fields with explicit labels
name_match = re.search(r"(?:name|nama)[:\s]+(.+)", line, re.IGNORECASE)
if name_match and not fields["name"]:
fields["name"] = name_match.group(1).strip()
continue
# Fallback: use first line that looks like a proper name
if not fields["name"]:
for line in lines:
if line.strip() and re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+", line.strip()):
fields["name"] = line.strip()
break
return fields
Connect Spring Boot to the OCR microservice
Your OCR service is running independently. Now you need your Spring Boot backend to call it when a visitor is created so the guard does not have to manually transcribe ID details.
- First, add the OCR service URL to your Spring Boot configuration. Open src/main/resources/application.yml and add this property below your existing configuration:
# OCR microservice connection
ocr:
service:
url: http://localhost:8001
Why externalize the URL?
This keeps the OCR service address in configuration rather than hardcoded in Java. When you deploy to AWS later, you can change this to the remote address without modifying any Java code.
- Save application.yml.
Now create the Java service class that communicates with the OCR microservice.
- In IntelliJ, create a new file at src/main/java/.../service/OcrClient.java and paste:
package com.gateguard.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Service
public class OcrClient {
private final RestTemplate restTemplate;
// Read the OCR service URL from application.yml
@Value("${ocr.service.url}")
private String ocrServiceUrl;
public OcrClient() {
this.restTemplate = new RestTemplate();
}
/**
* Send a base64-encoded image to the OCR microservice
* and return the extraction result.
*/
public OcrResult extractText(String imageBase64) {
String url = ocrServiceUrl + "/api/ocr/extract";
// Build the JSON request body
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> requestBody = Map.of("imageBase64", imageBase64);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
// POST to the OCR service and map the response
return restTemplate.postForObject(url, entity, OcrResult.class);
}
/**
* Response DTO matching the FastAPI OcrResponse schema.
*/
public static class OcrResult {
private String rawText;
private Map<String, String> parsedFields;
public String getRawText() { return rawText; }
public void setRawText(String rawText) { this.rawText = rawText; }
public Map<String, String> getParsedFields() { return parsedFields; }
public void setParsedFields(Map<String, String> parsedFields) { this.parsedFields = parsedFields; }
}
}
What does this code do?
- Uses Spring's RestTemplate to make a synchronous HTTP POST to the Python OCR service.
- Sends the base64 image as a JSON body and maps the response to an OcrResult object with rawText and parsedFields.
- The @Value annotation injects the URL from application.yml so you never hardcode connection details.
- Save OcrClient.java.
Now wire the OcrClient into the visitor creation flow so OCR runs automatically.
- Open src/main/java/.../controller/VisitorController.java.
- Replace the entire file content with the updated version that includes OCR integration:
package com.gateguard.controller;
import com.gateguard.entity.Visitor;
import com.gateguard.repository.VisitorRepository;
import com.gateguard.service.OcrClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/visitors")
public class VisitorController {
@Autowired
private VisitorRepository visitorRepository;
@Autowired
private OcrClient ocrClient;
@PostMapping
public ResponseEntity<Visitor> createVisitor(@RequestBody Visitor visitor) {
// Save the visitor first
Visitor saved = visitorRepository.save(visitor);
// If an ID photo URL is provided, run OCR extraction
if (saved.getIdPhotoUrl() != null && !saved.getIdPhotoUrl().isEmpty()) {
try {
// Call the OCR microservice with the ID photo
OcrClient.OcrResult result = ocrClient.extractText(saved.getIdPhotoUrl());
// Store the raw OCR text in the visitor record
saved.setOcrData(result.getRawText());
visitorRepository.save(saved);
} catch (Exception e) {
// Log but don't fail visitor creation if OCR is unavailable
System.err.println("OCR extraction failed: " + e.getMessage());
}
}
return ResponseEntity.ok(saved);
}
}
What does this code do?
- After saving the visitor, it checks if an idPhotoUrl was provided.
- If yes, it calls ocrClient.extractText() with the photo data and stores the result in the visitor's ocrData field.
- The try-catch ensures visitor creation still succeeds even if the OCR service is down. OCR is a convenience feature, not a blocker.
In production, use the actual image bytes
Right now, idPhotoUrl holds the base64-encoded image string directly. When you integrate S3 in the next step, the flow will download the image from S3, encode it to base64, and send it to the OCR service.
For now, you can test by passing a base64 string directly in the idPhotoUrl field.
- Save VisitorController.java.
- Restart your Spring Boot application in IntelliJ.
- Make sure the OCR service is still running in its own terminal on port 8001.
- Test the full flow in Postman by sending a POST request to http://localhost:8080/api/visitors with this JSON body:
{
"name": "Test Visitor",
"idPhotoUrl": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
"livePhotoUrl": ""
}
What should I see?
You should receive a 200 response with the visitor object. The ocrData field will contain whatever text PaddleOCR extracted (likely empty for a 1x1 test image, but present in the response).
Check the OCR service terminal as well. You should see a request logged there, confirming the two services communicated successfully.
Getting a connection refused error?
- Verify the OCR service is running by opening http://localhost:8001/docs in your browser. You should see the FastAPI auto-generated documentation page.
- Check that application.yml has ocr.service.url: http://localhost:8001 and that Spring Boot restarted after the change.
- If you see a timeout, PaddleOCR might still be downloading models on first invocation. Wait for the model download to complete in the OCR service terminal.
help me fix the connection between my Spring Boot app and the FastAPI OCR service
✔️ Awesome, I've got everything!
Great. Double check you have saved both Java files and that both services are running.
ⓧ I'd like to double check the full code
package com.gateguard.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Service
public class OcrClient {
private final RestTemplate restTemplate;
// Read the OCR service URL from application.yml
@Value("${ocr.service.url}")
private String ocrServiceUrl;
public OcrClient() {
this.restTemplate = new RestTemplate();
}
/**
* Send a base64-encoded image to the OCR microservice
* and return the extraction result.
*/
public OcrResult extractText(String imageBase64) {
String url = ocrServiceUrl + "/api/ocr/extract";
// Build the JSON request body
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> requestBody = Map.of("imageBase64", imageBase64);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
// POST to the OCR service and map the response
return restTemplate.postForObject(url, entity, OcrResult.class);
}
/**
* Response DTO matching the FastAPI OcrResponse schema.
*/
public static class OcrResult {
private String rawText;
private Map<String, String> parsedFields;
public String getRawText() { return rawText; }
public void setRawText(String rawText) { this.rawText = rawText; }
public Map<String, String> getParsedFields() { return parsedFields; }
public void setParsedFields(Map<String, String> parsedFields) { this.parsedFields = parsedFields; }
}
}
package com.gateguard.controller;
import com.gateguard.entity.Visitor;
import com.gateguard.repository.VisitorRepository;
import com.gateguard.service.OcrClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/visitors")
public class VisitorController {
@Autowired
private VisitorRepository visitorRepository;
@Autowired
private OcrClient ocrClient;
@PostMapping
public ResponseEntity<Visitor> createVisitor(@RequestBody Visitor visitor) {
// Save the visitor first
Visitor saved = visitorRepository.save(visitor);
// If an ID photo URL is provided, run OCR extraction
if (saved.getIdPhotoUrl() != null && !saved.getIdPhotoUrl().isEmpty()) {
try {
// Call the OCR microservice with the ID photo
OcrClient.OcrResult result = ocrClient.extractText(saved.getIdPhotoUrl());
// Store the raw OCR text in the visitor record
saved.setOcrData(result.getRawText());
visitorRepository.save(saved);
} catch (Exception e) {
// Log but don't fail visitor creation if OCR is unavailable
System.err.println("OCR extraction failed: " + e.getMessage());
}
}
return ResponseEntity.ok(saved);
}
}
# Add to existing application.yml
# OCR microservice connection
ocr:
service:
url: http://localhost:8001
Your two services are now talking to each other. When a guard takes a photo of a visitor's ID, the backend automatically extracts text from it and stores it alongside the visitor record. Next up, you will integrate S3 for image storage so photos persist in the cloud instead of being passed as raw base64 strings.
Integrate S3 Image Storage
Your backend can process visitor photos through OCR, but those images still need a permanent home. Right now there is no durable storage for ID photos or live visitor captures.
In this step you will create a private S3 bucket, wire the AWS SDK into Spring Boot, and build upload and retrieval endpoints. By the end, the entire flow from image upload to presigned URL access will work end to end.
In this step, get ready to:
- Create a private S3 bucket and block all public access.
- Add the AWS SDK dependency and build an S3Service with upload and presigned URL methods.
- Create an ImageController and wire image uploads into the visitor creation flow.
Create a private S3 bucket
GateGuard needs a place to store visitor ID photos and live captures securely. An S3 bucket with all public access blocked ensures only your backend can read and write images via presigned URLs.
- Create the S3 bucket in us-east-1 by running this command:
aws s3api create-bucket \
--bucket gateguard-images-[[YOURNAME="your-name-lowercase"]] \
--region us-east-1
Why no LocationConstraint?
When you create a bucket in us-east-1, AWS does not require a LocationConstraint parameter. Including one for this region actually causes an error.
- Block all public access on the bucket by running:
aws s3api put-public-access-block \
--bucket gateguard-images-[[YOURNAME="your-name-lowercase"]] \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
What does this command do?
This enables all four Block Public Access settings on your bucket. Even if someone accidentally attaches a public bucket policy later, S3 will reject it.
- Verify the public access block is active by running:
aws s3api get-public-access-block \
--bucket gateguard-images-[[YOURNAME="your-name-lowercase"]]
You should see all four values set to true in the JSON output.
Seeing "BucketAlreadyExists"?
S3 bucket names are globally unique across all AWS accounts. Append extra characters (your initials, a number) to make the name unique.
help me fix my S3 bucket creation error
- Update application.yml with your actual bucket name. Find the S3 placeholder section and replace it with:
aws:
s3:
bucket: gateguard-images-[[YOURNAME="your-name-lowercase"]]
region: us-east-1
What does this configure?
These properties inject the bucket name and region into your Spring beans at runtime. The AWS SDK automatically picks up credentials from your ~/.aws/credentials file that you configured earlier with aws configure.
- Save application.yml.
Add the AWS SDK and build S3Service
With the bucket ready, your Spring Boot app needs the AWS SDK to interact with it. You will add the SDK dependency, configure client beans, and create a service class.
- Add the AWS SDK BOM and S3 dependency to your pom.xml. Inside the <dependencyManagement> section, add:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.42.23</version>
<type>pom</type>
<scope>import</scope>
</dependency>
- Then add the S3 module in your regular <dependencies> section:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
What is a BOM?
A Bill of Materials (BOM) locks all AWS SDK module versions together so you never get version conflicts. You declare the version once in dependencyManagement and omit it from individual modules.
- Create a new file S3Config.java in your config package. Copy the following code into it:
package com.gateguard.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
@Configuration
public class S3Config {
@Value("${aws.region}")
private String region;
// Creates the S3 client used for uploads
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.build();
}
// Creates the presigner used for generating temporary download URLs
@Bean(destroyMethod = "close")
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.region(Region.of(region))
.build();
}
}
What does this code do?
- S3Client handles operations like uploading files to your bucket.
- S3Presigner generates time-limited URLs so mobile clients can download private images without AWS credentials.
- Both pick up credentials automatically from ~/.aws/credentials via the default credential provider chain.
- Create S3Service.java in your service package. Copy the following code into it:
package com.gateguard.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import java.io.IOException;
import java.time.Duration;
import java.util.UUID;
@Service
public class S3Service {
private final S3Client s3Client;
private final S3Presigner s3Presigner;
@Value("${aws.s3.bucket}")
private String bucketName;
public S3Service(S3Client s3Client, S3Presigner s3Presigner) {
this.s3Client = s3Client;
this.s3Presigner = s3Presigner;
}
// Uploads a file to S3 and returns the object key
public String uploadFile(MultipartFile file, String key) throws IOException {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build();
s3Client.putObject(request,
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
return key;
}
// Generates a presigned GET URL valid for 1 hour
public String generatePresignedUrl(String key) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofHours(1))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
return presignedRequest.url().toString();
}
}
What does this code do?
- uploadFile() takes a Spring MultipartFile and a key string, then streams the file content directly to S3.
- generatePresignedUrl() creates a temporary URL that expires after 1 hour. Anyone with the URL can download the image during that window without needing AWS credentials.
- contentLength is required for the synchronous fromInputStream call. Without it the SDK throws an error.
- Save both files.
- Rebuild the project in IntelliJ by clicking **Build**, then **Build Project**.
The project should compile without errors.
Seeing import errors?
If IntelliJ shows red underlines on software.amazon.awssdk imports, right-click your pom.xml and select **Maven**, then **Reload Project** to download the new dependency.
help me fix AWS SDK import errors in IntelliJ
Create the ImageController and wire uploads to visitor creation
Now you need REST endpoints that the mobile app can call to upload photos and retrieve them. You will also update the visitor creation flow to accept S3 keys instead of raw URLs.
- Create ImageController.java in your controller package. Copy the following code into it:
package com.gateguard.controller;
import com.gateguard.service.S3Service;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/images")
public class ImageController {
private final S3Service s3Service;
public ImageController(S3Service s3Service) {
this.s3Service = s3Service;
}
// Accepts a multipart file upload and stores it in S3
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> uploadImage(
@RequestParam("file") MultipartFile file) throws IOException {
// Generate a unique key to avoid collisions
String key = "visitors/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
s3Service.uploadFile(file, key);
return ResponseEntity.ok(Map.of("key", key));
}
// Returns a presigned URL for temporary access to a stored image
@GetMapping("/{key}")
public ResponseEntity<Map<String, String>> getImageUrl(
@PathVariable String key) {
String url = s3Service.generatePresignedUrl(key);
return ResponseEntity.ok(Map.of("url", url));
}
}
What does this code do?
- POST /api/images/upload accepts a multipart file, generates a unique S3 key prefixed with visitors/, uploads the file, and returns the key.
- GET /api/images/{key} takes an S3 key and returns a presigned URL valid for 1 hour.
- The mobile app uploads a photo, receives the key, and passes it when creating a visitor record.
- Now update VisitorController.java to accept S3 keys for photos. Find the POST /api/visitors endpoint and ensure it accepts idPhotoKey and livePhotoKey fields in the request body.
- Store these keys directly in the idPhotoUrl and livePhotoUrl fields of the Visitor entity.
- Save ImageController.java and VisitorController.java.
- Restart your Spring Boot application.
Why store keys instead of full URLs?
Presigned URLs expire. If you stored the full URL, it would become invalid after 1 hour. By storing only the S3 key, you generate a fresh presigned URL whenever the image needs to be accessed.
Now test the entire flow in Postman to confirm images upload to S3 and come back via presigned URLs.
- Open Postman and create a new POST request to http://localhost:8080/api/images/upload.
- Set the request body type to **form-data**.
- Add a key named file, change its type to **File**, and select any image from your computer.
- Add your JWT token in the **Authorization** header as Bearer <your-token>.
- Click **Send**.
You should receive a 200 response with a JSON body containing a key field like visitors/abc123_photo.jpg.
- Copy the key value from the response.
- Create a GET request to http://localhost:8080/api/images/<paste-your-key-here>.
- Click **Send**.
You should receive a JSON response containing a url field with a long AWS presigned URL.
- Paste the presigned URL into a new browser tab.
Your uploaded image should display in the browser.
- Finally, test the visitor creation flow. Create a POST request to http://localhost:8080/api/visitors with a JSON body that includes idPhotoKey and livePhotoKey set to the S3 key you received.
- Click **Send** and verify the visitor is created with the S3 keys stored.
Getting a 403 from S3 on upload?
Make sure your AWS CLI credentials have s3:PutObject and s3:GetObject permissions on your bucket. If you are using the root account credentials from aws configure, permissions are not the issue. Double-check that the bucket name in application.yml matches exactly what you created.
help me debug my S3 upload 403 error
Your S3 image pipeline is complete. Next up, you will build the React Native mobile app that captures photos from the guard's camera and uploads them through this exact flow.
Build the React Native Mobile App
Your backend can handle visitors, store images, extract text from IDs, and authenticate users. Now you need a mobile interface that puts all of this into the hands of subdivision guards and homeowners.
In this step, you will build a React Native app using Expo. Guards will use the app to photograph visitor IDs, review OCR-extracted text, search for the destination homeowner, and submit visit logs. Homeowners will use it to approve or deny visitors, pre-register expected guests, submit complaints, and view their payment balance.
In this step, get ready to:
- Set up an Expo project with all navigation and camera dependencies.
- Build the guard interface with camera capture, OCR review, homeowner search, and visit submission.
- Build the homeowner interface with visitor approvals, prior advise, complaints, and payments.
Create the Expo project and install dependencies
Expo is a framework that simplifies React Native development. It includes a development client called Expo Go that lets you run your app on a physical phone without compiling native code.
- In your terminal, navigate to your Desktop by running this command:
cd ~/Desktop
- Create the Expo project by running:
npx create-expo-app@latest GateGuardMobile
What does this command do?
This scaffolds a new React Native project using Expo SDK 54. It creates a GateGuardMobile folder with all the boilerplate files, a package.json, and a starter App.js file.
- Move into the new project folder by running:
cd GateGuardMobile
- Install the navigation libraries by running:
npm install @react-navigation/native @react-navigation/bottom-tabs @react-navigation/stack react-native-screens react-native-safe-area-context axios @react-native-async-storage/async-storage
What are these packages?
- @react-navigation/native is the core navigation framework for React Native apps.
- @react-navigation/bottom-tabs creates tab bar navigation at the bottom of the screen.
- @react-navigation/stack creates screen-to-screen navigation with a back button.
- axios handles HTTP requests to your Spring Boot backend.
- @react-native-async-storage/async-storage stores the JWT token persistently on the device.
- Install the Expo-specific packages by running:
npx expo install expo-camera expo-image-picker
- Confirm all packages installed successfully by running:
cat package.json | grep -E "expo-camera|expo-image-picker|react-navigation|axios"
You should see version entries for each of the packages you just installed.
Installation failed?
If you see permission errors, try running the npm install command with sudo (macOS). If you see peer dependency warnings, these are usually non-blocking for Expo projects.
help me fix my npm install error in the GateGuardMobile project
- Now create the folder structure for your screens and navigation by running:
mkdir screens navigation
- Start the Expo development server by running:
npx expo start
You should see a QR code in your terminal along with a menu of options.
- On your phone, open the Expo Go app (download it from the App Store or Play Store if you have not already).
- Scan the QR code displayed in your terminal. Your phone and computer must be on the same Wi-Fi network.
You should see the default Expo starter screen on your phone. This confirms the project is running correctly.
- Press Ctrl+C in your terminal to stop the dev server for now. You will restart it after adding your screens.
QR code not connecting?
Make sure your phone and computer are on the same Wi-Fi network. If you are on a corporate or university network, try using a personal hotspot instead.
help me connect Expo Go to my development server
Build the authentication and guard screens
The app needs to know whether a user is a guard or homeowner so it can show the correct interface. You will build a login flow that stores the JWT token and user role, then routes the user to either the guard tabs or the homeowner tabs.
- Open the GateGuardMobile project in VS Code.
- Create screens/LoginScreen.js and add the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Base URL for your Spring Boot backend
// Replace with your computer's local IP address (not localhost)
const API_BASE = 'http://192.168.1.100:8080';
export default function LoginScreen({ navigation }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
setLoading(true);
try {
// Send login credentials to the backend
const response = await axios.post(`${API_BASE}/api/auth/login`, {
email,
password,
});
// Store the JWT token and user role locally
const { token, role, userId } = response.data;
await AsyncStorage.setItem('token', token);
await AsyncStorage.setItem('role', role);
await AsyncStorage.setItem('userId', String(userId));
// Navigate to the correct screen based on role
navigation.replace('Main');
} catch (error) {
Alert.alert('Login Failed', 'Invalid email or password.');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>GateGuard</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity style={styles.button} onPress={handleLogin} disabled={loading}>
<Text style={styles.buttonText}>{loading ? 'Logging in...' : 'Login'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.linkText}>Don't have an account? Register</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: '#f5f5f5' },
title: { fontSize: 32, fontWeight: 'bold', textAlign: 'center', marginBottom: 40 },
input: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 15, fontSize: 16 },
button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
linkText: { textAlign: 'center', marginTop: 20, color: '#007AFF' },
});
What does this code do?
- The API_BASE constant points to your Spring Boot backend. You must use your computer's local IP address because localhost on a phone refers to the phone itself.
- handleLogin sends credentials to POST /api/auth/login and stores the returned JWT token, role, and user ID in AsyncStorage.
- After login, the app navigates to Main which will route to either the guard or homeowner tabs based on the stored role.
Find your local IP address
On macOS, open System Settings then Wi-Fi and click Details on your connected network. Your IP will look like 192.168.x.x. Replace the IP in API_BASE with yours.
- Create screens/RegisterScreen.js and add the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
const API_BASE = 'http://192.168.1.100:8080';
export default function RegisterScreen({ navigation }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('HOMEOWNER');
const handleRegister = async () => {
try {
// Create the new user account
await axios.post(`${API_BASE}/api/auth/register`, {
name,
email,
password,
role,
});
Alert.alert('Success', 'Account created. Please log in.');
navigation.navigate('Login');
} catch (error) {
Alert.alert('Registration Failed', 'Could not create account.');
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Register</Text>
<TextInput
style={styles.input}
placeholder="Full Name"
value={name}
onChangeText={setName}
/>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{/* Role selector */}
<View style={styles.roleContainer}>
<TouchableOpacity
style={[styles.roleButton, role === 'HOMEOWNER' && styles.roleActive]}
onPress={() => setRole('HOMEOWNER')}
>
<Text style={styles.roleText}>Homeowner</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'GUARD' && styles.roleActive]}
onPress={() => setRole('GUARD')}
>
<Text style={styles.roleText}>Guard</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.button} onPress={handleRegister}>
<Text style={styles.buttonText}>Register</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Login')}>
<Text style={styles.linkText}>Already have an account? Login</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: '#f5f5f5' },
title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center', marginBottom: 30 },
input: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 15, fontSize: 16 },
roleContainer: { flexDirection: 'row', justifyContent: 'center', marginBottom: 20 },
roleButton: { padding: 12, borderRadius: 8, marginHorizontal: 8, backgroundColor: '#ddd' },
roleActive: { backgroundColor: '#007AFF' },
roleText: { color: '#fff', fontWeight: '600' },
button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
linkText: { textAlign: 'center', marginTop: 20, color: '#007AFF' },
});
What does this code do?
The registration form collects name, email, password, and role. It sends this to POST /api/auth/register on your backend. The role selector lets users register as either a HOMEOWNER or GUARD.
- Create navigation/GuardNavigator.js and add the following code:
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ScanVisitorScreen from '../screens/ScanVisitorScreen';
import VisitLogScreen from '../screens/VisitLogScreen';
// Create the bottom tab navigator for guards
const Tab = createBottomTabNavigator();
export default function GuardNavigator() {
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: 'gray',
headerShown: true,
}}
>
<Tab.Screen
name="ScanVisitor"
component={ScanVisitorScreen}
options={{ title: 'Scan Visitor' }}
/>
<Tab.Screen
name="VisitLog"
component={VisitLogScreen}
options={{ title: 'Visit Log' }}
/>
</Tab.Navigator>
);
}
What does this code do?
This creates a bottom tab bar with two tabs for guards. The Scan Visitor tab opens the camera and check-in flow. The Visit Log tab shows a history of processed visitors.
- Create screens/ScanVisitorScreen.js with the full guard check-in flow:
import React, { useState, useRef } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Alert, Image } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function ScanVisitorScreen() {
// Camera state
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef(null);
const [idPhoto, setIdPhoto] = useState(null);
const [livePhoto, setLivePhoto] = useState(null);
const [captureMode, setCaptureMode] = useState('id'); // 'id' or 'live'
// OCR and form state
const [ocrText, setOcrText] = useState('');
const [visitorName, setVisitorName] = useState('');
const [block, setBlock] = useState('');
const [lot, setLot] = useState('');
const [phase, setPhase] = useState('');
const [purpose, setPurpose] = useState('');
const [approvalStatus, setApprovalStatus] = useState(null);
const [step, setStep] = useState('camera'); // 'camera', 'review', 'search', 'result'
// Take a photo using the CameraView ref
const takePhoto = async () => {
if (cameraRef.current) {
const photo = await cameraRef.current.takePictureAsync({ base64: true });
if (captureMode === 'id') {
setIdPhoto(photo);
// Switch to live photo capture
setCaptureMode('live');
} else {
setLivePhoto(photo);
// Both photos taken, upload them
await uploadPhotos(idPhoto, photo);
}
}
};
// Upload photos to backend S3 endpoint
const uploadPhotos = async (idImg, liveImg) => {
try {
const token = await AsyncStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
// Upload ID photo
const idFormData = new FormData();
idFormData.append('file', {
uri: idImg.uri,
type: 'image/jpeg',
name: 'id_photo.jpg',
});
const idUpload = await axios.post(`${API_BASE}/api/images/upload`, idFormData, {
headers: { ...headers, 'Content-Type': 'multipart/form-data' },
});
// Upload live photo
const liveFormData = new FormData();
liveFormData.append('file', {
uri: liveImg.uri,
type: 'image/jpeg',
name: 'live_photo.jpg',
});
const liveUpload = await axios.post(`${API_BASE}/api/images/upload`, liveFormData, {
headers: { ...headers, 'Content-Type': 'multipart/form-data' },
});
// Create visitor record, which triggers OCR on the backend
const visitorResponse = await axios.post(
`${API_BASE}/api/visitors`,
{
idPhotoKey: idUpload.data.key,
livePhotoKey: liveUpload.data.key,
},
{ headers }
);
// Display the OCR-extracted text for review
const visitor = visitorResponse.data;
setOcrText(visitor.ocrData || 'No text extracted');
setVisitorName(visitor.name || '');
setStep('review');
} catch (error) {
Alert.alert('Upload Error', 'Failed to upload photos. Check your connection.');
}
};
// Submit the check-in to the backend
const submitCheckIn = async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.post(
`${API_BASE}/api/visits/check-in`,
{
visitorName,
block,
lot,
phase,
purpose,
},
{ headers: { Authorization: `Bearer ${token}` } }
);
setApprovalStatus(response.data.status);
setStep('result');
} catch (error) {
Alert.alert('Error', 'Failed to submit check-in.');
}
};
// Reset the flow for the next visitor
const resetFlow = () => {
setIdPhoto(null);
setLivePhoto(null);
setCaptureMode('id');
setOcrText('');
setVisitorName('');
setBlock('');
setLot('');
setPhase('');
setPurpose('');
setApprovalStatus(null);
setStep('camera');
};
// Handle camera permissions
if (!permission) {
return <View style={styles.container}><Text>Loading...</Text></View>;
}
if (!permission.granted) {
return (
<View style={styles.container}>
<Text style={styles.message}>Camera access is needed to scan visitor IDs.</Text>
<TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant Permission</Text>
</TouchableOpacity>
</View>
);
}
// Camera capture screen
if (step === 'camera') {
return (
<View style={styles.cameraContainer}>
<Text style={styles.cameraLabel}>
{captureMode === 'id' ? 'Capture ID Photo' : 'Capture Live Photo'}
</Text>
<CameraView ref={cameraRef} style={styles.camera} facing="back" />
<TouchableOpacity style={styles.captureButton} onPress={takePhoto}>
<Text style={styles.buttonText}>Take Photo</Text>
</TouchableOpacity>
</View>
);
}
// OCR review screen
if (step === 'review') {
return (
<ScrollView style={styles.formContainer}>
<Text style={styles.sectionTitle}>OCR Extracted Text</Text>
<Text style={styles.ocrText}>{ocrText}</Text>
<Text style={styles.sectionTitle}>Visitor Name</Text>
<TextInput
style={styles.input}
value={visitorName}
onChangeText={setVisitorName}
placeholder="Edit visitor name if needed"
/>
<TouchableOpacity style={styles.button} onPress={() => setStep('search')}>
<Text style={styles.buttonText}>Next: Find Homeowner</Text>
</TouchableOpacity>
</ScrollView>
);
}
// Homeowner search and purpose input
if (step === 'search') {
return (
<ScrollView style={styles.formContainer}>
<Text style={styles.sectionTitle}>Destination Homeowner</Text>
<TextInput style={styles.input} placeholder="Block" value={block} onChangeText={setBlock} />
<TextInput style={styles.input} placeholder="Lot" value={lot} onChangeText={setLot} />
<TextInput style={styles.input} placeholder="Phase" value={phase} onChangeText={setPhase} />
<Text style={styles.sectionTitle}>Visit Purpose</Text>
<TextInput
style={styles.input}
placeholder="e.g. Delivery, Personal visit"
value={purpose}
onChangeText={setPurpose}
/>
<TouchableOpacity style={styles.button} onPress={submitCheckIn}>
<Text style={styles.buttonText}>Submit Check-In</Text>
</TouchableOpacity>
</ScrollView>
);
}
// Result screen
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Check-In Result</Text>
<Text style={styles.statusText}>
Status: {approvalStatus || 'PENDING_APPROVAL'}
</Text>
<TouchableOpacity style={styles.button} onPress={resetFlow}>
<Text style={styles.buttonText}>Scan Next Visitor</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
cameraContainer: { flex: 1 },
camera: { flex: 1 },
cameraLabel: { textAlign: 'center', padding: 10, fontSize: 18, fontWeight: '600', backgroundColor: '#333', color: '#fff' },
captureButton: { backgroundColor: '#007AFF', padding: 15, alignItems: 'center' },
formContainer: { flex: 1, padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginTop: 15, marginBottom: 8 },
ocrText: { backgroundColor: '#eee', padding: 12, borderRadius: 8, fontSize: 14, marginBottom: 15 },
input: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, alignItems: 'center', marginTop: 10 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
message: { fontSize: 16, textAlign: 'center', marginBottom: 20 },
statusText: { fontSize: 22, fontWeight: 'bold', marginVertical: 20 },
});
What does this code do?
This is the most complex screen in the app. It manages a multi-step flow using a step state variable.
- Camera step: Uses CameraView from expo-camera to capture an ID photo, then a live visitor photo.
- Upload step: Sends both photos to POST /api/images/upload, then creates a visitor record that triggers OCR extraction on the backend.
- Review step: Displays extracted OCR text and lets the guard edit the visitor name.
- Search step: The guard enters the destination homeowner's block, lot, and phase plus the visit purpose.
- Result step: Shows the approval status returned by POST /api/visits/check-in.
- Create screens/VisitLogScreen.js and add the following code:
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function VisitLogScreen() {
const [logs, setLogs] = useState([]);
useEffect(() => {
fetchLogs();
}, []);
// Fetch all visit logs for this guard
const fetchLogs = async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.get(`${API_BASE}/api/visit-logs`, {
headers: { Authorization: `Bearer ${token}` },
});
setLogs(response.data);
} catch (error) {
console.log('Failed to fetch logs:', error);
}
};
const renderItem = ({ item }) => (
<View style={styles.logItem}>
<Text style={styles.visitorName}>{item.visitorName}</Text>
<Text style={styles.details}>Purpose: {item.purpose}</Text>
<Text style={styles.details}>Status: {item.status}</Text>
<Text style={styles.timestamp}>{new Date(item.timestamp).toLocaleString()}</Text>
</View>
);
return (
<View style={styles.container}>
<FlatList
data={logs}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
ListEmptyComponent={<Text style={styles.empty}>No visit logs yet.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 15, backgroundColor: '#f5f5f5' },
logItem: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 10 },
visitorName: { fontSize: 16, fontWeight: '600' },
details: { fontSize: 14, color: '#555', marginTop: 4 },
timestamp: { fontSize: 12, color: '#999', marginTop: 6 },
empty: { textAlign: 'center', marginTop: 40, color: '#999', fontSize: 16 },
});
What does this code do?
This screen fetches all visit logs from GET /api/visit-logs and displays them in a scrollable list. Each item shows the visitor name, purpose, status, and timestamp.
- Save all the files you have created so far.
Camera not showing on your phone?
Make sure you are testing on a physical device, not a simulator. The camera does not work in iOS Simulator or Android Emulator. Also ensure you granted camera permissions when prompted.
help me fix the camera not appearing in my Expo app
Build the homeowner screens and wire up the app
Homeowners need a different set of screens. They approve or deny visitors, pre-register expected guests, submit complaints, and view their payment balance. You will also create the root App.js that routes users to the correct navigator based on their role.
- Create navigation/HomeownerNavigator.js and add the following code:
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import HomeScreen from '../screens/HomeScreen';
import HistoryScreen from '../screens/HistoryScreen';
import ProfileScreen from '../screens/ProfileScreen';
// Bottom tab navigator for homeowners
const Tab = createBottomTabNavigator();
export default function HomeownerNavigator() {
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: 'gray',
headerShown: true,
}}
>
<Tab.Screen name="Home" component={HomeScreen} options={{ title: 'Home' }} />
<Tab.Screen name="History" component={HistoryScreen} options={{ title: 'History' }} />
<Tab.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
</Tab.Navigator>
);
}
What does this code do?
This creates a three-tab navigator for homeowners. The Home tab shows pending visitor approvals. History shows past visits. Profile links to Prior Advise, Complaints, and Payments screens.
- Create screens/HomeScreen.js for pending visitor approvals:
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function HomeScreen() {
const [pending, setPending] = useState([]);
useEffect(() => {
fetchPending();
}, []);
// Fetch pending visitor approvals for this homeowner
const fetchPending = async () => {
try {
const token = await AsyncStorage.getItem('token');
const userId = await AsyncStorage.getItem('userId');
const response = await axios.get(
`${API_BASE}/api/visit-logs/homeowner/${userId}?status=PENDING_APPROVAL`,
{ headers: { Authorization: `Bearer ${token}` } }
);
setPending(response.data);
} catch (error) {
console.log('Failed to fetch pending:', error);
}
};
// Approve or deny a visitor
const handleDecision = async (logId, decision) => {
try {
const token = await AsyncStorage.getItem('token');
await axios.put(
`${API_BASE}/api/visit-logs/${logId}/decision`,
{ status: decision },
{ headers: { Authorization: `Bearer ${token}` } }
);
Alert.alert('Done', `Visitor ${decision.toLowerCase()}.`);
fetchPending();
} catch (error) {
Alert.alert('Error', 'Could not process decision.');
}
};
const renderItem = ({ item }) => (
<View style={styles.card}>
<Text style={styles.name}>{item.visitorName}</Text>
<Text style={styles.detail}>Purpose: {item.purpose}</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionBtn, styles.approve]}
onPress={() => handleDecision(item.id, 'APPROVED')}
>
<Text style={styles.actionText}>Approve</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.deny]}
onPress={() => handleDecision(item.id, 'DENIED')}
>
<Text style={styles.actionText}>Deny</Text>
</TouchableOpacity>
</View>
</View>
);
return (
<View style={styles.container}>
<FlatList
data={pending}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
ListEmptyComponent={<Text style={styles.empty}>No pending visitors.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 15, backgroundColor: '#f5f5f5' },
card: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 12 },
name: { fontSize: 16, fontWeight: '600' },
detail: { fontSize: 14, color: '#555', marginTop: 4 },
actions: { flexDirection: 'row', marginTop: 12 },
actionBtn: { flex: 1, padding: 10, borderRadius: 6, alignItems: 'center', marginHorizontal: 5 },
approve: { backgroundColor: '#34C759' },
deny: { backgroundColor: '#FF3B30' },
actionText: { color: '#fff', fontWeight: '600' },
empty: { textAlign: 'center', marginTop: 40, color: '#999', fontSize: 16 },
});
What does this code do?
This screen fetches visit logs with PENDING_APPROVAL status for the current homeowner. Each card shows the visitor name and purpose with Approve and Deny buttons that send the decision to the backend.
- Create screens/HistoryScreen.js and add the following code:
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function HistoryScreen() {
const [history, setHistory] = useState([]);
useEffect(() => {
fetchHistory();
}, []);
// Fetch visit history for the current homeowner
const fetchHistory = async () => {
try {
const token = await AsyncStorage.getItem('token');
const userId = await AsyncStorage.getItem('userId');
const response = await axios.get(
`${API_BASE}/api/visit-logs/homeowner/${userId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
setHistory(response.data);
} catch (error) {
console.log('Failed to fetch history:', error);
}
};
const renderItem = ({ item }) => (
<View style={styles.item}>
<Text style={styles.name}>{item.visitorName}</Text>
<Text style={styles.detail}>Purpose: {item.purpose}</Text>
<Text style={styles.detail}>Status: {item.status}</Text>
<Text style={styles.date}>{new Date(item.timestamp).toLocaleString()}</Text>
</View>
);
return (
<View style={styles.container}>
<FlatList
data={history}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
ListEmptyComponent={<Text style={styles.empty}>No visit history.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 15, backgroundColor: '#f5f5f5' },
item: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 10 },
name: { fontSize: 16, fontWeight: '600' },
detail: { fontSize: 14, color: '#555', marginTop: 4 },
date: { fontSize: 12, color: '#999', marginTop: 6 },
empty: { textAlign: 'center', marginTop: 40, color: '#999', fontSize: 16 },
});
What does this code do?
This fetches all visit logs (regardless of status) from GET /api/visit-logs/homeowner/{id} and displays them chronologically. Homeowners can see who visited and whether they were approved or denied.
- Create screens/PriorAdviseScreen.js and add the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function PriorAdviseScreen({ navigation }) {
const [visitorName, setVisitorName] = useState('');
const [expectedDate, setExpectedDate] = useState('');
const [purpose, setPurpose] = useState('');
// Submit a prior advise for pre-approved visitor
const handleSubmit = async () => {
try {
const token = await AsyncStorage.getItem('token');
await axios.post(
`${API_BASE}/api/prior-advises`,
{ visitorName, expectedDate, purpose },
{ headers: { Authorization: `Bearer ${token}` } }
);
Alert.alert('Success', 'Visitor pre-approved.');
setVisitorName('');
setExpectedDate('');
setPurpose('');
} catch (error) {
Alert.alert('Error', 'Could not submit prior advise.');
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Pre-Register a Visitor</Text>
<TextInput
style={styles.input}
placeholder="Visitor Name"
value={visitorName}
onChangeText={setVisitorName}
/>
<TextInput
style={styles.input}
placeholder="Expected Date (YYYY-MM-DD)"
value={expectedDate}
onChangeText={setExpectedDate}
/>
<TextInput
style={styles.input}
placeholder="Visit Purpose"
value={purpose}
onChangeText={setPurpose}
/>
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Submit Prior Advise</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#f5f5f5' },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 20 },
input: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, alignItems: 'center', marginTop: 10 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
What does this code do?
This form lets homeowners pre-register an expected visitor by name, date, and purpose. When submitted to POST /api/prior-advises, the backend will auto-approve that visitor when a guard checks them in.
- Create screens/ComplaintsScreen.js and add the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function ComplaintsScreen() {
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
// Submit a complaint or suggestion
const handleSubmit = async () => {
try {
const token = await AsyncStorage.getItem('token');
await axios.post(
`${API_BASE}/api/complaints`,
{ subject, description },
{ headers: { Authorization: `Bearer ${token}` } }
);
Alert.alert('Submitted', 'Your complaint has been filed.');
setSubject('');
setDescription('');
} catch (error) {
Alert.alert('Error', 'Could not submit complaint.');
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Submit a Complaint</Text>
<TextInput
style={styles.input}
placeholder="Subject"
value={subject}
onChangeText={setSubject}
/>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="Describe your concern..."
value={description}
onChangeText={setDescription}
multiline
numberOfLines={5}
/>
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Submit Complaint</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#f5f5f5' },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 20 },
input: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
textArea: { height: 120, textAlignVertical: 'top' },
button: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, alignItems: 'center', marginTop: 10 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
What does this code do?
Homeowners use this form to submit feedback or complaints to the subdivision admin. The subject and description are sent to POST /api/complaints. Admins will later review these on the web dashboard.
- Create screens/PaymentsScreen.js and add the following code:
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const API_BASE = 'http://192.168.1.100:8080';
export default function PaymentsScreen() {
const [balance, setBalance] = useState(0);
const [payments, setPayments] = useState([]);
useEffect(() => {
fetchPaymentData();
}, []);
// Fetch balance and payment history
const fetchPaymentData = async () => {
try {
const token = await AsyncStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
const balanceRes = await axios.get(`${API_BASE}/api/payments/balance`, { headers });
setBalance(balanceRes.data.balance);
const historyRes = await axios.get(`${API_BASE}/api/payments/history`, { headers });
setPayments(historyRes.data);
} catch (error) {
console.log('Failed to fetch payment data:', error);
}
};
// Placeholder for Stripe payment (will be connected in a later step)
const handlePayNow = () => {
Alert.alert('Coming Soon', 'Stripe payment will be connected in a later step.');
};
const renderPayment = ({ item }) => (
<View style={styles.paymentItem}>
<Text style={styles.amount}>PHP {item.amount}</Text>
<Text style={styles.date}>{new Date(item.paidAt).toLocaleDateString()}</Text>
</View>
);
return (
<View style={styles.container}>
<View style={styles.balanceCard}>
<Text style={styles.balanceLabel}>Current HOA Dues Balance</Text>
<Text style={styles.balanceAmount}>PHP {balance}</Text>
<TouchableOpacity style={styles.payButton} onPress={handlePayNow}>
<Text style={styles.payButtonText}>Pay Now</Text>
</TouchableOpacity>
</View>
<Text style={styles.sectionTitle}>Payment History</Text>
<FlatList
data={payments}
keyExtractor={(item) => String(item.id)}
renderItem={renderPayment}
ListEmptyComponent={<Text style={styles.empty}>No payments yet.</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 15, backgroundColor: '#f5f5f5' },
balanceCard: { backgroundColor: '#007AFF', padding: 25, borderRadius: 12, alignItems: 'center', marginBottom: 20 },
balanceLabel: { color: '#fff', fontSize: 14, opacity: 0.8 },
balanceAmount: { color: '#fff', fontSize: 32, fontWeight: 'bold', marginVertical: 10 },
payButton: { backgroundColor: '#fff', paddingHorizontal: 30, paddingVertical: 10, borderRadius: 8 },
payButtonText: { color: '#007AFF', fontWeight: '600', fontSize: 16 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
paymentItem: { backgroundColor: '#fff', padding: 15, borderRadius: 8, marginBottom: 8, flexDirection: 'row', justifyContent: 'space-between' },
amount: { fontSize: 16, fontWeight: '600' },
date: { fontSize: 14, color: '#999' },
empty: { textAlign: 'center', marginTop: 20, color: '#999' },
});
What does this code do?
This screen shows the homeowner's current HOA dues balance and payment history. The Pay Now button is a placeholder for now. You will connect it to Stripe in a later step.
- Create a simple screens/ProfileScreen.js that links to the other homeowner features:
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function ProfileScreen({ navigation }) {
// Navigate to sub-screens
const goTo = (screen) => navigation.navigate(screen);
// Log out and return to login screen
const handleLogout = async () => {
await AsyncStorage.clear();
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
};
return (
<View style={styles.container}>
<TouchableOpacity style={styles.menuItem} onPress={() => goTo('PriorAdvise')}>
<Text style={styles.menuText}>Prior Advise (Pre-Register Visitors)</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => goTo('Complaints')}>
<Text style={styles.menuText}>Complaints & Suggestions</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => goTo('Payments')}>
<Text style={styles.menuText}>Payments</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.menuItem, styles.logout]} onPress={handleLogout}>
<Text style={[styles.menuText, { color: '#FF3B30' }]}>Logout</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#f5f5f5' },
menuItem: { backgroundColor: '#fff', padding: 18, borderRadius: 8, marginBottom: 12 },
menuText: { fontSize: 16, fontWeight: '500' },
logout: { marginTop: 20, borderWidth: 1, borderColor: '#FF3B30', backgroundColor: '#fff' },
});
What does this code do?
The Profile screen acts as a menu linking to Prior Advise, Complaints, and Payments screens. It also includes a Logout button that clears AsyncStorage and returns to the login screen.
- Now wire everything together. Replace the contents of App.js in the project root with the following code:
import React, { useState, useEffect } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Auth screens
import LoginScreen from './screens/LoginScreen';
import RegisterScreen from './screens/RegisterScreen';
// Navigators
import GuardNavigator from './navigation/GuardNavigator';
import HomeownerNavigator from './navigation/HomeownerNavigator';
// Homeowner sub-screens accessed from Profile
import PriorAdviseScreen from './screens/PriorAdviseScreen';
import ComplaintsScreen from './screens/ComplaintsScreen';
import PaymentsScreen from './screens/PaymentsScreen';
const Stack = createStackNavigator();
// Main navigator that shows either Guard or Homeowner tabs based on stored role
function MainNavigator() {
const [role, setRole] = useState(null);
useEffect(() => {
const loadRole = async () => {
const storedRole = await AsyncStorage.getItem('role');
setRole(storedRole);
};
loadRole();
}, []);
if (!role) return null;
// Return the correct tab navigator based on user role
if (role === 'GUARD') {
return <GuardNavigator />;
}
return (
<Stack.Navigator>
<Stack.Screen name="HomeTabs" component={HomeownerNavigator} options={{ headerShown: false }} />
<Stack.Screen name="PriorAdvise" component={PriorAdviseScreen} options={{ title: 'Prior Advise' }} />
<Stack.Screen name="Complaints" component={ComplaintsScreen} options={{ title: 'Complaints' }} />
<Stack.Screen name="Payments" component={PaymentsScreen} options={{ title: 'Payments' }} />
</Stack.Navigator>
);
}
export default function App() {
const [isLoggedIn, setIsLoggedIn] = useState(null);
useEffect(() => {
// Check if a token already exists
const checkAuth = async () => {
const token = await AsyncStorage.getItem('token');
setIsLoggedIn(!!token);
};
checkAuth();
}, []);
if (isLoggedIn === null) return null;
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</>
) : (
<Stack.Screen name="Main" component={MainNavigator} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
What does this code do?
This is the entry point of your entire app. It checks AsyncStorage for an existing token to decide whether to show the auth screens (Login/Register) or the main app.
The MainNavigator component reads the stored role. Guards see the GuardNavigator with Scan Visitor and Visit Log tabs. Homeowners see the HomeownerNavigator with Home, History, and Profile tabs, plus stack screens for Prior Advise, Complaints, and Payments.
- Save all files.
- Start the Expo development server by running:
npx expo start
- Scan the QR code with Expo Go on your phone.
You should see the Login screen with a GateGuard title, email and password fields, a Login button, and a Register link.
Seeing a red error screen?
Check the error message in the terminal. Common causes include missing import statements, typos in file paths, or a missing screen file. Make sure every file in the screens/ and navigation/ folders is saved and matches the filenames in your imports.
help me fix the red error screen in my Expo app
✔️ Awesome, I see the Login screen!
Great work. Your React Native app is running with all screens connected. Make sure you have saved every file.
ⓧ I'd like to double check the full code
Here is the complete App.js that ties everything together:
import React, { useState, useEffect } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Auth screens
import LoginScreen from './screens/LoginScreen';
import RegisterScreen from './screens/RegisterScreen';
// Navigators
import GuardNavigator from './navigation/GuardNavigator';
import HomeownerNavigator from './navigation/HomeownerNavigator';
// Homeowner sub-screens accessed from Profile
import PriorAdviseScreen from './screens/PriorAdviseScreen';
import ComplaintsScreen from './screens/ComplaintsScreen';
import PaymentsScreen from './screens/PaymentsScreen';
const Stack = createStackNavigator();
// Main navigator that shows either Guard or Homeowner tabs based on stored role
function MainNavigator() {
const [role, setRole] = useState(null);
useEffect(() => {
const loadRole = async () => {
const storedRole = await AsyncStorage.getItem('role');
setRole(storedRole);
};
loadRole();
}, []);
if (!role) return null;
// Return the correct tab navigator based on user role
if (role === 'GUARD') {
return <GuardNavigator />;
}
return (
<Stack.Navigator>
<Stack.Screen name="HomeTabs" component={HomeownerNavigator} options={{ headerShown: false }} />
<Stack.Screen name="PriorAdvise" component={PriorAdviseScreen} options={{ title: 'Prior Advise' }} />
<Stack.Screen name="Complaints" component={ComplaintsScreen} options={{ title: 'Complaints' }} />
<Stack.Screen name="Payments" component={PaymentsScreen} options={{ title: 'Payments' }} />
</Stack.Navigator>
);
}
export default function App() {
const [isLoggedIn, setIsLoggedIn] = useState(null);
useEffect(() => {
// Check if a token already exists
const checkAuth = async () => {
const token = await AsyncStorage.getItem('token');
setIsLoggedIn(!!token);
};
checkAuth();
}, []);
if (isLoggedIn === null) return null;
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isLoggedIn ? (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</>
) : (
<Stack.Screen name="Main" component={MainNavigator} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
Your mobile app is running on a real device with full navigation for both guards and homeowners. Next up, you will add push notifications using RabbitMQ so homeowners get alerted the moment a visitor arrives at their gate.
Set Up the Notification System
Your guard check-in flow captures visitor data, uploads photos, and logs visits. But homeowners have no way of knowing when someone arrives at the gate for them.
In this step, you will build a notification pipeline using RabbitMQ as a message broker. When a guard processes a visitor check-in, the backend publishes a message to a queue. A consumer picks up that message, persists a notification to the database, and the mobile app polls for new notifications every 30 seconds.
In this step, get ready to:
- Configure Spring AMQP and build a notification producer that publishes messages to RabbitMQ.
- Build a notification consumer and REST API endpoints for fetching and reading notifications.
- Add notification polling to the React Native mobile app with a badge count on the Home tab.
Configure RabbitMQ and build the notification producer
Spring Boot provides the spring-boot-starter-amqp starter for working with RabbitMQ. You need to add this dependency, configure the connection, and create a producer service that publishes messages whenever a visitor checks in.
- Add the Spring AMQP dependency to your pom.xml by pasting this inside the <dependencies> block:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
What does this dependency do?
The spring-boot-starter-amqp pulls in Spring AMQP and the RabbitMQ client library. It auto-configures a ConnectionFactory, RabbitTemplate (for sending messages), and a listener container factory (for consuming messages).
- Save pom.xml.
- Reload Maven dependencies in IntelliJ by clicking the Maven refresh icon in the top-right corner of the editor, or by pressing Cmd+Shift+I (macOS).
- Add the RabbitMQ connection settings to your application.yml by pasting this configuration below your existing properties:
# RabbitMQ connection settings
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
# Custom property for the notification queue name
notification:
queue:
name: notification-queue
What does this configuration do?
The spring.rabbitmq block tells Spring Boot how to connect to your local RabbitMQ container (running on port 5672 with default guest/guest credentials).
The notification.queue.name is a custom property you will reference in your Java code with @Value annotations. This keeps the queue name in one place rather than hardcoded across multiple files.
- Save application.yml.
- Create a new file RabbitMQConfig.java in your config package and paste the following:
package com.gateguard.config;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Value("${notification.queue.name}")
private String queueName;
// Declare the notification queue as a durable queue
@Bean
public Queue notificationQueue() {
return new Queue(queueName, true);
}
// Use JSON for message serialization so messages are readable
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
}
What does this configuration class do?
The Queue bean declares a durable queue named notification-queue on RabbitMQ. Spring Boot's auto-configured RabbitAdmin automatically creates this queue on the broker when the application starts.
The Jackson2JsonMessageConverter ensures that messages are serialized as JSON instead of Java's default binary serialization. This makes messages human-readable in the RabbitMQ management UI and compatible with any consumer language.
- Save RabbitMQConfig.java.
- Create a new file NotificationProducer.java in a messaging package and paste the following:
package com.gateguard.messaging;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class NotificationProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${notification.queue.name}")
private String queueName;
public NotificationProducer(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
// Publish a visitor check-in notification to the queue
public void publishVisitorNotification(Long homeownerId, String visitorName, Long visitLogId) {
Map<String, Object> message = new HashMap<>();
message.put("homeownerId", homeownerId);
message.put("visitorName", visitorName);
message.put("visitLogId", visitLogId);
message.put("type", "VISITOR_CHECK_IN");
rabbitTemplate.convertAndSend(queueName, message);
}
}
What does the producer do?
The RabbitTemplate is Spring's convenience class for sending messages. The convertAndSend method serializes the Map to JSON (using the converter you configured) and publishes it to the notification-queue.
Other parts of your application will call publishVisitorNotification() whenever a visitor targets a specific homeowner. The message includes the homeowner ID, visitor name, and visit log ID so the consumer has all the context it needs.
- Save NotificationProducer.java.
- Restart your Spring Boot application. You should see a log line mentioning the RabbitMQ connection (something like Created new connection: rabbitConnectionFactory) in the console output.
Not seeing the RabbitMQ connection log?
Make sure your RabbitMQ Docker container is still running. Check with docker ps and verify the container is up on port 5672.
If you see a Connection refused error, restart RabbitMQ with docker start followed by your container name or ID.
help me fix RabbitMQ connection errors in Spring Boot
Build the notification consumer and API endpoints
With the producer ready, you need something to consume those messages. The consumer listens to the queue, persists each notification to the database, and logs the delivery. Then you will build REST endpoints so the mobile app can fetch and acknowledge notifications.
- Create a new file NotificationConsumer.java in the messaging package and paste the following:
package com.gateguard.messaging;
import com.gateguard.entity.Notification;
import com.gateguard.repository.NotificationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
@Service
public class NotificationConsumer {
private static final Logger log = LoggerFactory.getLogger(NotificationConsumer.class);
private final NotificationRepository notificationRepository;
public NotificationConsumer(NotificationRepository notificationRepository) {
this.notificationRepository = notificationRepository;
}
// Listen for messages on the notification queue
@RabbitListener(queues = "${notification.queue.name}")
public void handleNotification(Map<String, Object> message) {
Long homeownerId = ((Number) message.get("homeownerId")).longValue();
String visitorName = (String) message.get("visitorName");
Long visitLogId = ((Number) message.get("visitLogId")).longValue();
// Persist the notification to the database
Notification notification = new Notification();
notification.setUserId(homeownerId);
notification.setTitle("Visitor Arrival");
notification.setMessage(visitorName + " has checked in to visit you.");
notification.setVisitLogId(visitLogId);
notification.setRead(false);
notification.setCreatedAt(LocalDateTime.now());
notificationRepository.save(notification);
// Log push notification delivery
log.info("Push notification sent to homeowner {}: {}", homeownerId, visitorName);
}
}
What does the consumer do?
The @RabbitListener annotation tells Spring to call handleNotification() whenever a new message arrives on notification-queue. Spring automatically deserializes the JSON message back into a Map.
The method extracts the homeowner ID, visitor name, and visit log ID from the message. It then creates a Notification entity and saves it to the database. The log.info line is a placeholder for a real push notification service. In production, you would integrate with Firebase Cloud Messaging or APNs here.
- Save NotificationConsumer.java.
- Create a new file NotificationController.java in the controller package and paste the following:
package com.gateguard.controller;
import com.gateguard.entity.Notification;
import com.gateguard.repository.NotificationRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationRepository notificationRepository;
public NotificationController(NotificationRepository notificationRepository) {
this.notificationRepository = notificationRepository;
}
// Get all unread notifications for the currently authenticated user
@GetMapping("/me")
public ResponseEntity<List<Notification>> getMyNotifications(
@AuthenticationPrincipal Long userId) {
List<Notification> notifications = notificationRepository
.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
return ResponseEntity.ok(notifications);
}
// Mark a specific notification as read
@PutMapping("/{id}/read")
public ResponseEntity<Void> markAsRead(@PathVariable Long id) {
notificationRepository.findById(id).ifPresent(notification -> {
notification.setRead(true);
notificationRepository.save(notification);
});
return ResponseEntity.ok().build();
}
}
What do these endpoints do?
GET /api/notifications/me returns all unread notifications for the authenticated user, sorted newest first. The mobile app will poll this endpoint every 30 seconds.
PUT /api/notifications/{id}/read lets the user dismiss a notification. Once marked as read, it will no longer appear in the unread list.
- Save NotificationController.java.
Now you need to wire the producer into the visitor check-in flow so that notifications are published when a guard processes a visitor.
- In VisitCheckInController.java, find the constructor and add NotificationProducer as a dependency.
- After the line where you save the visit log to the database, add the following call:
// Notify the homeowner about the visitor check-in
notificationProducer.publishVisitorNotification(
homeowner.getId(),
visitor.getName(),
visitLog.getId()
);
What does this change do?
After the visit log is persisted, the controller publishes a notification message to RabbitMQ. The consumer picks it up asynchronously, meaning the check-in API response is not blocked by notification delivery.
This decoupling is the key benefit of using a message queue. If notification processing is slow or fails, it does not affect the guard's check-in experience.
- Save VisitCheckInController.java and restart your Spring Boot application.
- Open the RabbitMQ management UI at http://localhost:15672 (login with guest/guest). Click the Queues tab and you should see notification-queue listed.
Don't see the queue in the management UI?
Make sure your Spring Boot application started without errors. The queue is declared when the app connects to RabbitMQ on startup.
If the management UI itself is not loading at port 15672, confirm your RabbitMQ container was started with the management plugin enabled.
help me troubleshoot my RabbitMQ queue not appearing
Add notification polling to the mobile app
The backend notification system is complete. Now you need the React Native app to fetch notifications periodically so homeowners see alerts when visitors arrive.
- Open GateGuardMobile/screens/HomeScreen.js in VS Code and replace its contents with the following:
import React, { useState, useCallback } from 'react';
import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useFocusEffect } from '@react-navigation/native';
const API_URL = 'http://localhost:8080';
export default function HomeScreen() {
const [notifications, setNotifications] = useState([]);
// Fetch unread notifications from the backend
const fetchNotifications = async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/notifications/me`, {
headers: { Authorization: `Bearer ${token}` },
});
setNotifications(response.data);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
};
// Poll for notifications every 30 seconds while screen is focused
useFocusEffect(
useCallback(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, [])
);
// Mark a notification as read when tapped
const markAsRead = async (id) => {
try {
const token = await AsyncStorage.getItem('token');
await axios.put(`${API_URL}/api/notifications/${id}/read`, null, {
headers: { Authorization: `Bearer ${token}` },
});
fetchNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
// Render each notification as a tappable card
const renderNotification = ({ item }) => (
<TouchableOpacity
style={styles.notificationCard}
onPress={() => markAsRead(item.id)}
>
<Text style={styles.notificationTitle}>{item.title}</Text>
<Text style={styles.notificationMessage}>{item.message}</Text>
<Text style={styles.notificationTime}>
{new Date(item.createdAt).toLocaleString()}
</Text>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<Text style={styles.header}>
Notifications ({notifications.length})
</Text>
<FlatList
data={notifications}
keyExtractor={(item) => item.id.toString()}
renderItem={renderNotification}
ListEmptyComponent={
<Text style={styles.emptyText}>No new notifications.</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#fff' },
header: { fontSize: 22, fontWeight: 'bold', marginBottom: 16 },
notificationCard: {
padding: 12,
borderRadius: 8,
backgroundColor: '#f0f4ff',
marginBottom: 10,
},
notificationTitle: { fontSize: 16, fontWeight: '600' },
notificationMessage: { fontSize: 14, color: '#333', marginTop: 4 },
notificationTime: { fontSize: 12, color: '#888', marginTop: 6 },
emptyText: { textAlign: 'center', color: '#999', marginTop: 40 },
});
What does this screen do?
The useFocusEffect hook runs whenever the Home screen gains focus. It immediately fetches notifications, then starts a 30-second polling interval. When the screen loses focus, clearInterval stops the polling to avoid unnecessary network calls.
Each notification renders as a tappable card. Tapping it calls PUT /api/notifications/{id}/read and refreshes the list so the dismissed notification disappears.
- Save HomeScreen.js.
Next, you will add a badge count to the Home tab so homeowners can see unread notifications at a glance, even when viewing other screens.
- Open GateGuardMobile/navigation/HomeownerNavigator.js and replace its contents with the following:
import React, { useState, useEffect, useCallback } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import HomeScreen from '../screens/HomeScreen';
import HistoryScreen from '../screens/HistoryScreen';
import ProfileScreen from '../screens/ProfileScreen';
const Tab = createBottomTabNavigator();
const API_URL = 'http://localhost:8080';
export default function HomeownerNavigator() {
const [badgeCount, setBadgeCount] = useState(0);
// Fetch unread notification count for the tab badge
const fetchBadgeCount = useCallback(async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/notifications/me`, {
headers: { Authorization: `Bearer ${token}` },
});
setBadgeCount(response.data.length);
} catch (error) {
console.error('Failed to fetch badge count:', error);
}
}, []);
// Poll badge count every 30 seconds
useEffect(() => {
fetchBadgeCount();
const interval = setInterval(fetchBadgeCount, 30000);
return () => clearInterval(interval);
}, [fetchBadgeCount]);
return (
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarBadge: badgeCount > 0 ? badgeCount : undefined,
}}
/>
<Tab.Screen name="History" component={HistoryScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
What does the badge do?
The tabBarBadge option on the Home tab accepts a number or string. When badgeCount is greater than zero, a small red badge appears on the Home tab icon showing the count. Setting it to undefined hides the badge entirely.
The useEffect hook polls independently of the Home screen. This means the badge updates even when the homeowner is on the History or Profile tab.
- Save HomeownerNavigator.js.
- In your terminal, make sure your Expo dev server is running with npx expo start.
- Open the app in Expo Go on your phone. Log in as a homeowner and navigate to the Home tab. You should see the notifications list (empty if no visitors have checked in yet).
Test the full notification flow
To verify the end-to-end flow, use Postman to call POST /api/visits/check-in with a guard JWT token, targeting a homeowner. Within 30 seconds, the homeowner's Home tab should show a new notification card and the badge count should increment.
Badge not showing or notifications not appearing?
Confirm your Spring Boot app restarted without errors after adding the NotificationProducer call to the check-in controller.
Check that your API_URL in the React Native files points to your machine's local IP (not localhost) if testing on a physical device. Expo Go on a phone cannot reach the computer's localhost directly.
help me debug why notifications are not appearing in my React Native app
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and that your Spring Boot app restarted successfully with the RabbitMQ connection.
ⓧ I'd like to double check the full code
Here is the complete RabbitMQConfig.java:
package com.gateguard.config;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Value("${notification.queue.name}")
private String queueName;
// Declare the notification queue as a durable queue
@Bean
public Queue notificationQueue() {
return new Queue(queueName, true);
}
// Use JSON for message serialization so messages are readable
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
}
Here is the complete NotificationProducer.java:
package com.gateguard.messaging;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class NotificationProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${notification.queue.name}")
private String queueName;
public NotificationProducer(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
// Publish a visitor check-in notification to the queue
public void publishVisitorNotification(Long homeownerId, String visitorName, Long visitLogId) {
Map<String, Object> message = new HashMap<>();
message.put("homeownerId", homeownerId);
message.put("visitorName", visitorName);
message.put("visitLogId", visitLogId);
message.put("type", "VISITOR_CHECK_IN");
rabbitTemplate.convertAndSend(queueName, message);
}
}
Here is the complete NotificationConsumer.java:
package com.gateguard.messaging;
import com.gateguard.entity.Notification;
import com.gateguard.repository.NotificationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
@Service
public class NotificationConsumer {
private static final Logger log = LoggerFactory.getLogger(NotificationConsumer.class);
private final NotificationRepository notificationRepository;
public NotificationConsumer(NotificationRepository notificationRepository) {
this.notificationRepository = notificationRepository;
}
// Listen for messages on the notification queue
@RabbitListener(queues = "${notification.queue.name}")
public void handleNotification(Map<String, Object> message) {
Long homeownerId = ((Number) message.get("homeownerId")).longValue();
String visitorName = (String) message.get("visitorName");
Long visitLogId = ((Number) message.get("visitLogId")).longValue();
// Persist the notification to the database
Notification notification = new Notification();
notification.setUserId(homeownerId);
notification.setTitle("Visitor Arrival");
notification.setMessage(visitorName + " has checked in to visit you.");
notification.setVisitLogId(visitLogId);
notification.setRead(false);
notification.setCreatedAt(LocalDateTime.now());
notificationRepository.save(notification);
// Log push notification delivery
log.info("Push notification sent to homeowner {}: {}", homeownerId, visitorName);
}
}
Here is the complete NotificationController.java:
package com.gateguard.controller;
import com.gateguard.entity.Notification;
import com.gateguard.repository.NotificationRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationRepository notificationRepository;
public NotificationController(NotificationRepository notificationRepository) {
this.notificationRepository = notificationRepository;
}
// Get all unread notifications for the currently authenticated user
@GetMapping("/me")
public ResponseEntity<List<Notification>> getMyNotifications(
@AuthenticationPrincipal Long userId) {
List<Notification> notifications = notificationRepository
.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
return ResponseEntity.ok(notifications);
}
// Mark a specific notification as read
@PutMapping("/{id}/read")
public ResponseEntity<Void> markAsRead(@PathVariable Long id) {
notificationRepository.findById(id).ifPresent(notification -> {
notification.setRead(true);
notificationRepository.save(notification);
});
return ResponseEntity.ok().build();
}
}
Here is the complete HomeScreen.js:
import React, { useState, useCallback } from 'react';
import { View, Text, FlatList, StyleSheet, TouchableOpacity } from 'react-native';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useFocusEffect } from '@react-navigation/native';
const API_URL = 'http://localhost:8080';
export default function HomeScreen() {
const [notifications, setNotifications] = useState([]);
// Fetch unread notifications from the backend
const fetchNotifications = async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/notifications/me`, {
headers: { Authorization: `Bearer ${token}` },
});
setNotifications(response.data);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
};
// Poll for notifications every 30 seconds while screen is focused
useFocusEffect(
useCallback(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, [])
);
// Mark a notification as read when tapped
const markAsRead = async (id) => {
try {
const token = await AsyncStorage.getItem('token');
await axios.put(`${API_URL}/api/notifications/${id}/read`, null, {
headers: { Authorization: `Bearer ${token}` },
});
fetchNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
// Render each notification as a tappable card
const renderNotification = ({ item }) => (
<TouchableOpacity
style={styles.notificationCard}
onPress={() => markAsRead(item.id)}
>
<Text style={styles.notificationTitle}>{item.title}</Text>
<Text style={styles.notificationMessage}>{item.message}</Text>
<Text style={styles.notificationTime}>
{new Date(item.createdAt).toLocaleString()}
</Text>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<Text style={styles.header}>
Notifications ({notifications.length})
</Text>
<FlatList
data={notifications}
keyExtractor={(item) => item.id.toString()}
renderItem={renderNotification}
ListEmptyComponent={
<Text style={styles.emptyText}>No new notifications.</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#fff' },
header: { fontSize: 22, fontWeight: 'bold', marginBottom: 16 },
notificationCard: {
padding: 12,
borderRadius: 8,
backgroundColor: '#f0f4ff',
marginBottom: 10,
},
notificationTitle: { fontSize: 16, fontWeight: '600' },
notificationMessage: { fontSize: 14, color: '#333', marginTop: 4 },
notificationTime: { fontSize: 12, color: '#888', marginTop: 6 },
emptyText: { textAlign: 'center', color: '#999', marginTop: 40 },
});
Here is the complete HomeownerNavigator.js:
import React, { useState, useEffect, useCallback } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import HomeScreen from '../screens/HomeScreen';
import HistoryScreen from '../screens/HistoryScreen';
import ProfileScreen from '../screens/ProfileScreen';
const Tab = createBottomTabNavigator();
const API_URL = 'http://localhost:8080';
export default function HomeownerNavigator() {
const [badgeCount, setBadgeCount] = useState(0);
// Fetch unread notification count for the tab badge
const fetchBadgeCount = useCallback(async () => {
try {
const token = await AsyncStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/notifications/me`, {
headers: { Authorization: `Bearer ${token}` },
});
setBadgeCount(response.data.length);
} catch (error) {
console.error('Failed to fetch badge count:', error);
}
}, []);
// Poll badge count every 30 seconds
useEffect(() => {
fetchBadgeCount();
const interval = setInterval(fetchBadgeCount, 30000);
return () => clearInterval(interval);
}, [fetchBadgeCount]);
return (
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarBadge: badgeCount > 0 ? badgeCount : undefined,
}}
/>
<Tab.Screen name="History" component={HistoryScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
Why polling instead of WebSockets?
Polling every 30 seconds is simpler to implement and debug than maintaining a persistent WebSocket connection. For a subdivision with a few hundred homeowners, the network overhead is minimal.
The Secret Mission at the end of this project upgrades this to real-time WebSocket notifications for instant delivery. Polling gives you a solid working baseline first.
Your notification pipeline is live. When a guard checks in a visitor, the backend publishes a message to RabbitMQ, the consumer persists it, and the mobile app picks it up within 30 seconds. Next up, you will build the React web admin dashboard for managing the entire subdivision.
Build the React Web Admin Dashboard
Your mobile app gives guards and homeowners everything they need on the ground. But administrators need a bird's-eye view of the entire subdivision.
In this step, you'll build a React web dashboard that displays visitor trends, homeowner data, complaint management, and summary statistics in one place. You'll use Vite to scaffold the project, Tailwind CSS for styling, and Recharts for data visualizations.
In this step, get ready to:
- Scaffold a React project with Vite and configure Tailwind CSS, routing, and API dependencies.
- Build a dashboard page with summary cards and interactive charts using Recharts.
- Create management pages for homeowners, visitor logs, and complaints with search, filtering, and status controls.
Create the React project and install dependencies
Vite is the recommended way to create new React apps. It provides instant hot module replacement and fast builds without the complexity of server-side rendering frameworks.
- Open a new terminal window. Move to your Desktop by running this command:
cd ~/Desktop
- Create the admin dashboard project by running:
npm create vite@latest gateguard-admin -- --template react
What does this command do?
This scaffolds a new React project in a folder called gateguard-admin using the React template. Vite pre-configures the build tool, dev server, and React plugin for you.
- Move into the new project folder and install the base dependencies by running:
cd gateguard-admin
npm install
- Install the project-specific packages by running:
npm install axios react-router-dom recharts tailwindcss @tailwindcss/vite
What are these packages?
- axios makes HTTP requests to your Spring Boot backend.
- react-router-dom handles page navigation without full page reloads.
- recharts provides pre-built chart components (line, pie, bar) for data visualization.
- tailwindcss and @tailwindcss/vite provide utility-first CSS styling with zero configuration files needed.
- Confirm the packages installed by running:
ls node_modules/recharts node_modules/axios
You should see the folder contents listed for both packages without errors.
Seeing permission errors or EACCES?
Make sure you ran cd gateguard-admin before running npm install. Running install outside the project folder causes errors.
Help me debug my npm install error in the gateguard-admin project
Now configure Tailwind CSS. With Tailwind v4, you only need the Vite plugin and a CSS import.
- Open the gateguard-admin folder in VS Code.
- Open vite.config.js and replace its contents with this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Configure Vite with React and Tailwind CSS plugins
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
What does this do?
This registers both the React plugin (for JSX compilation and hot reload) and the Tailwind CSS plugin (for scanning your files and generating utility classes). No separate tailwind.config.js is needed with Tailwind v4.
- Open src/index.css and replace all its contents with this single line:
@import "tailwindcss";
- Save both files.
Next, set up the routing and page structure. Your admin dashboard needs five pages: login, dashboard, homeowners, visitors, and complaints.
- Create a pages folder inside src/ by running:
mkdir src/pages
- Open src/main.jsx and replace its contents with this code:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
// Wrap the app in BrowserRouter for client-side routing
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
What does this do?
BrowserRouter enables client-side navigation. When a user clicks a link in the admin dashboard, React swaps the page component without a full page reload. This keeps the app fast and responsive.
- Open src/App.jsx and replace its contents with this code:
import { Routes, Route, Navigate } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import HomeownersPage from './pages/HomeownersPage'
import VisitorsPage from './pages/VisitorsPage'
import ComplaintsPage from './pages/ComplaintsPage'
// Protected route wrapper that checks for JWT token
function ProtectedRoute({ children }) {
const token = localStorage.getItem('token')
if (!token) {
return <Navigate to="/login" replace />
}
return children
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={
<ProtectedRoute><DashboardPage /></ProtectedRoute>
} />
<Route path="/homeowners" element={
<ProtectedRoute><HomeownersPage /></ProtectedRoute>
} />
<Route path="/visitors" element={
<ProtectedRoute><VisitorsPage /></ProtectedRoute>
} />
<Route path="/complaints" element={
<ProtectedRoute><ComplaintsPage /></ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
export default App
What does this code do?
The ProtectedRoute component checks if a JWT token exists in localStorage. If there is no token, the user gets redirected to the login page. This keeps unauthenticated users away from the admin pages.
Each route maps a URL path to a page component. The catch-all path="*" sends any unknown URLs to the login page.
- Now create the login page. Create a new file at src/pages/LoginPage.jsx and add this code:
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
// Handle form submission and authenticate with backend
const handleLogin = async (e) => {
e.preventDefault()
setError('')
try {
const response = await axios.post('http://localhost:8080/api/auth/login', {
email,
password,
})
// Store the JWT token for authenticated requests
localStorage.setItem('token', response.data.token)
navigate('/dashboard')
} catch (err) {
setError('Invalid email or password. Please try again.')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h1 className="text-2xl font-bold mb-6 text-center">GateGuard Admin</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleLogin}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
>
Sign In
</button>
</form>
</div>
</div>
)
}
export default LoginPage
What does this code do?
The login page sends a POST request to your Spring Boot /api/auth/login endpoint. On success, it stores the JWT token in localStorage and redirects the admin to the dashboard.
The Tailwind classes handle all styling inline. No separate CSS file is needed for this page.
- Save all files.
- Start the dev server by running:
npm run dev
You will see errors in the terminal because the page components do not exist yet. That is expected. You will create them in the next substeps.
Seeing 'Cannot find module' errors?
This is normal. The App.jsx file imports page components that you have not created yet. Once you add them in the next substep, the errors will resolve.
If you see a different error (like a Tailwind plugin error), make sure your vite.config.js matches the code above and that you installed @tailwindcss/vite.
Help me debug my Vite project setup errors
Build the dashboard page with summary cards and charts
The dashboard is the admin's home screen. It shows key metrics at a glance and visualizes visitor patterns over time. You will use Recharts to render three chart types: a line chart for visitor trends, a pie chart for visit purposes, and a bar chart for peak hours.
- Create a new file at src/pages/DashboardPage.jsx and add this code:
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import {
LineChart, Line, PieChart, Pie, Cell, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts'
// Color palette for pie chart slices
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
function DashboardPage() {
const [stats, setStats] = useState({
visitorsToday: 0,
pendingApprovals: 0,
totalHomeowners: 0,
pendingComplaints: 0,
})
const [visitorTrends, setVisitorTrends] = useState([])
const [purposeData, setPurposeData] = useState([])
const [peakHoursData, setPeakHoursData] = useState([])
useEffect(() => {
const token = localStorage.getItem('token')
const headers = { Authorization: `Bearer ${token}` }
// Fetch summary statistics
axios.get('http://localhost:8080/api/admin/stats', { headers })
.then(res => setStats(res.data))
.catch(err => console.error('Failed to fetch stats:', err))
// Fetch visitor trend data for line chart
axios.get('http://localhost:8080/api/admin/visitor-trends', { headers })
.then(res => setVisitorTrends(res.data))
.catch(err => console.error('Failed to fetch trends:', err))
// Fetch visit purpose breakdown for pie chart
axios.get('http://localhost:8080/api/admin/visit-purposes', { headers })
.then(res => setPurposeData(res.data))
.catch(err => console.error('Failed to fetch purposes:', err))
// Fetch peak hours data for bar chart
axios.get('http://localhost:8080/api/admin/peak-hours', { headers })
.then(res => setPeakHoursData(res.data))
.catch(err => console.error('Failed to fetch peak hours:', err))
}, [])
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="font-bold text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Visitors Today</p>
<p className="text-3xl font-bold text-blue-600">{stats.visitorsToday}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Pending Approvals</p>
<p className="text-3xl font-bold text-yellow-600">{stats.pendingApprovals}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Total Homeowners</p>
<p className="text-3xl font-bold text-green-600">{stats.totalHomeowners}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Pending Complaints</p>
<p className="text-3xl font-bold text-red-600">{stats.pendingComplaints}</p>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Visitor Trends Line Chart */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Visitor Trends</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={visitorTrends}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="visitors" stroke="#0088FE" name="Visitors" />
</LineChart>
</ResponsiveContainer>
</div>
{/* Visit Purposes Pie Chart */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Visit Purposes</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={purposeData}
dataKey="count"
nameKey="purpose"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{purposeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
{/* Peak Hours Bar Chart */}
<div className="bg-white p-6 rounded-lg shadow lg:col-span-2">
<h2 className="text-lg font-semibold mb-4">Peak Hours</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={peakHoursData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Bar dataKey="visitors" fill="#8884d8" name="Visitors" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
}
export default DashboardPage
What does this code do?
- useEffect fetches four API endpoints when the page loads: stats, visitor trends, visit purposes, and peak hours.
- The summary cards display real-time counts for visitors today, pending approvals, total homeowners, and pending complaints.
- ResponsiveContainer makes each chart resize to fit its parent container. This keeps charts readable at any screen width.
- The COLORS array assigns distinct colors to each pie chart slice using the Cell component.
- Save DashboardPage.jsx.
The API endpoints do not exist yet
The dashboard calls endpoints like /api/admin/stats and /api/admin/visitor-trends that you have not built in your Spring Boot backend yet. The dashboard will display zeros and empty charts until you wire those endpoints. For now, focus on getting the frontend structure working.
Build the management pages
Admins need to search homeowners, review visitor logs, and manage complaints. Each of these becomes its own page with specific filtering and action controls.
- Create a new file at src/pages/HomeownersPage.jsx and add this code:
import { useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function HomeownersPage() {
const [searchQuery, setSearchQuery] = useState('')
const [homeowners, setHomeowners] = useState([])
const [selectedHomeowner, setSelectedHomeowner] = useState(null)
// Search homeowners by name or block/lot/phase
const handleSearch = async () => {
const token = localStorage.getItem('token')
try {
const response = await axios.get(
`http://localhost:8080/api/homeowners/search?query=${searchQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setHomeowners(response.data)
} catch (err) {
console.error('Search failed:', err)
}
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="font-bold text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Homeowners</h1>
{/* Search Bar */}
<div className="flex gap-2 mb-6">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name, block, lot, or phase..."
className="flex-1 border border-gray-300 rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={handleSearch}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Search
</button>
</div>
{/* Results Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Block/Lot</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Phase</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Contact</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody>
{homeowners.map((ho) => (
<tr key={ho.id} className="border-t">
<td className="px-4 py-3">{ho.firstName} {ho.lastName}</td>
<td className="px-4 py-3">Blk {ho.block} Lot {ho.lot}</td>
<td className="px-4 py-3">{ho.phase}</td>
<td className="px-4 py-3">{ho.contactNumber}</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedHomeowner(ho)}
className="text-blue-600 hover:underline"
>
View Details
</button>
</td>
</tr>
))}
</tbody>
</table>
{homeowners.length === 0 && (
<p className="p-4 text-gray-500 text-center">No homeowners found. Try a search above.</p>
)}
</div>
{/* Detail Modal */}
{selectedHomeowner && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-lg w-96">
<h2 className="text-xl font-bold mb-4">
{selectedHomeowner.firstName} {selectedHomeowner.lastName}
</h2>
<p className="mb-2"><span className="font-medium">Block:</span> {selectedHomeowner.block}</p>
<p className="mb-2"><span className="font-medium">Lot:</span> {selectedHomeowner.lot}</p>
<p className="mb-2"><span className="font-medium">Phase:</span> {selectedHomeowner.phase}</p>
<p className="mb-2"><span className="font-medium">Contact:</span> {selectedHomeowner.contactNumber}</p>
<p className="mb-4"><span className="font-medium">Email:</span> {selectedHomeowner.email}</p>
<button
onClick={() => setSelectedHomeowner(null)}
className="w-full bg-gray-200 py-2 rounded hover:bg-gray-300"
>
Close
</button>
</div>
</div>
)}
</div>
)
}
export default HomeownersPage
What does this code do?
The search input calls GET /api/homeowners/search with a query parameter. Results populate the table. Clicking View Details opens a modal overlay showing the homeowner's full information.
- Save HomeownersPage.jsx.
- Create a new file at src/pages/VisitorsPage.jsx and add this code:
import { useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function VisitorsPage() {
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [visitLogs, setVisitLogs] = useState([])
// Fetch visit logs filtered by date range
const handleFilter = async () => {
const token = localStorage.getItem('token')
try {
const response = await axios.get(
`http://localhost:8080/api/visit-logs?startDate=${startDate}&endDate=${endDate}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setVisitLogs(response.data)
} catch (err) {
console.error('Failed to fetch visit logs:', err)
}
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="font-bold text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Visitor Logs</h1>
{/* Date Range Filter */}
<div className="flex gap-4 mb-6 items-end">
<div>
<label className="block text-sm font-medium mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="border border-gray-300 rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="border border-gray-300 rounded px-3 py-2"
/>
</div>
<button
onClick={handleFilter}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Filter
</button>
</div>
{/* Visit Logs Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Date/Time</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Visitor</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Homeowner</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Purpose</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
{visitLogs.map((log) => (
<tr key={log.id} className="border-t">
<td className="px-4 py-3">{new Date(log.timestamp).toLocaleString()}</td>
<td className="px-4 py-3">{log.visitorName}</td>
<td className="px-4 py-3">{log.homeownerName}</td>
<td className="px-4 py-3">{log.purpose}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
log.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
log.status === 'DENIED' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{log.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{visitLogs.length === 0 && (
<p className="p-4 text-gray-500 text-center">No visit logs found. Select a date range and click Filter.</p>
)}
</div>
</div>
)
}
export default VisitorsPage
What does this code do?
The date range inputs let admins filter visit logs by start and end dates. The handleFilter function passes these dates as query parameters to GET /api/visit-logs. Status badges are color-coded: green for approved, red for denied, yellow for pending.
- Save VisitorsPage.jsx.
- Create the final page. Create a new file at src/pages/ComplaintsPage.jsx and add this code:
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function ComplaintsPage() {
const [complaints, setComplaints] = useState([])
const [aiSummary, setAiSummary] = useState('')
// Fetch all complaints on page load
useEffect(() => {
const token = localStorage.getItem('token')
axios.get('http://localhost:8080/api/complaints', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => setComplaints(res.data))
.catch(err => console.error('Failed to fetch complaints:', err))
}, [])
// Update a complaint's status (pending, reviewed, resolved)
const updateStatus = async (id, newStatus) => {
const token = localStorage.getItem('token')
try {
await axios.put(
`http://localhost:8080/api/complaints/${id}/status`,
{ status: newStatus },
{ headers: { Authorization: `Bearer ${token}` } }
)
// Update local state to reflect the change
setComplaints(complaints.map(c =>
c.id === id ? { ...c, status: newStatus } : c
))
} catch (err) {
console.error('Failed to update status:', err)
}
}
// Placeholder for AI summary generation (wired in a later step)
const generateAiSummary = () => {
setAiSummary('AI summary generation will be connected in the AI integration step.')
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="font-bold text-blue-600">Complaints</Link>
</nav>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Complaints & Suggestions</h1>
<button
onClick={generateAiSummary}
className="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"
>
Generate AI Summary
</button>
</div>
{/* AI Summary Display */}
{aiSummary && (
<div className="bg-purple-50 border border-purple-200 p-4 rounded-lg mb-6">
<h3 className="font-semibold text-purple-800 mb-2">AI Summary</h3>
<p className="text-purple-700">{aiSummary}</p>
</div>
)}
{/* Complaints List */}
<div className="space-y-4">
{complaints.map((complaint) => (
<div key={complaint.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{complaint.subject}</p>
<p className="text-sm text-gray-500 mt-1">
By {complaint.homeownerName} on {new Date(complaint.createdAt).toLocaleDateString()}
</p>
<p className="mt-2 text-gray-700">{complaint.description}</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
complaint.status === 'RESOLVED' ? 'bg-green-100 text-green-800' :
complaint.status === 'REVIEWED' ? 'bg-blue-100 text-blue-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{complaint.status}
</span>
</div>
{/* Status Update Buttons */}
<div className="mt-3 flex gap-2">
<button
onClick={() => updateStatus(complaint.id, 'REVIEWED')}
className="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded hover:bg-blue-200"
>
Mark Reviewed
</button>
<button
onClick={() => updateStatus(complaint.id, 'RESOLVED')}
className="text-sm bg-green-100 text-green-700 px-3 py-1 rounded hover:bg-green-200"
>
Mark Resolved
</button>
</div>
</div>
))}
{complaints.length === 0 && (
<p className="text-gray-500 text-center">No complaints found.</p>
)}
</div>
</div>
)
}
export default ComplaintsPage
What does this code do?
- The page loads all complaints on mount and displays them as cards with color-coded status badges (yellow for pending, blue for reviewed, green for resolved).
- updateStatus sends a PUT request to update a complaint's status, then immediately reflects the change in the UI without a page reload.
- The Generate AI Summary button is a placeholder. You will wire it to the Ollama AI service in a later step.
- Save ComplaintsPage.jsx.
- Check your terminal where npm run dev is running. The compile errors should now be gone.
- Open your browser to the URL shown in the terminal (usually http://localhost:5173).
You should see the GateGuard Admin login page with a centered card containing email and password fields.
Seeing a blank page or Tailwind styles not loading?
- Make sure src/index.css contains only @import "tailwindcss"; and nothing else.
- Check that vite.config.js has both react() and tailwindcss() in the plugins array.
- Try clearing the Vite cache by stopping the server (Ctrl+C) and running rm -rf node_modules/.vite && npm run dev.
Help me fix my Tailwind CSS styles not appearing in Vite
✔️ Awesome, I've got everything!
Great. Double check that you have saved all five page files and that the login page displays correctly in your browser.
ⓧ I'd like to double check the full code
Here are all the files you should have at this point.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Configure Vite with React and Tailwind CSS plugins
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
@import "tailwindcss";
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
// Wrap the app in BrowserRouter for client-side routing
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
import { Routes, Route, Navigate } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import HomeownersPage from './pages/HomeownersPage'
import VisitorsPage from './pages/VisitorsPage'
import ComplaintsPage from './pages/ComplaintsPage'
// Protected route wrapper that checks for JWT token
function ProtectedRoute({ children }) {
const token = localStorage.getItem('token')
if (!token) {
return <Navigate to="/login" replace />
}
return children
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={
<ProtectedRoute><DashboardPage /></ProtectedRoute>
} />
<Route path="/homeowners" element={
<ProtectedRoute><HomeownersPage /></ProtectedRoute>
} />
<Route path="/visitors" element={
<ProtectedRoute><VisitorsPage /></ProtectedRoute>
} />
<Route path="/complaints" element={
<ProtectedRoute><ComplaintsPage /></ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
export default App
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
// Handle form submission and authenticate with backend
const handleLogin = async (e) => {
e.preventDefault()
setError('')
try {
const response = await axios.post('http://localhost:8080/api/auth/login', {
email,
password,
})
// Store the JWT token for authenticated requests
localStorage.setItem('token', response.data.token)
navigate('/dashboard')
} catch (err) {
setError('Invalid email or password. Please try again.')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h1 className="text-2xl font-bold mb-6 text-center">GateGuard Admin</h1>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<form onSubmit={handleLogin}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
>
Sign In
</button>
</form>
</div>
</div>
)
}
export default LoginPage
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import {
LineChart, Line, PieChart, Pie, Cell, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts'
// Color palette for pie chart slices
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
function DashboardPage() {
const [stats, setStats] = useState({
visitorsToday: 0,
pendingApprovals: 0,
totalHomeowners: 0,
pendingComplaints: 0,
})
const [visitorTrends, setVisitorTrends] = useState([])
const [purposeData, setPurposeData] = useState([])
const [peakHoursData, setPeakHoursData] = useState([])
useEffect(() => {
const token = localStorage.getItem('token')
const headers = { Authorization: `Bearer ${token}` }
// Fetch summary statistics
axios.get('http://localhost:8080/api/admin/stats', { headers })
.then(res => setStats(res.data))
.catch(err => console.error('Failed to fetch stats:', err))
// Fetch visitor trend data for line chart
axios.get('http://localhost:8080/api/admin/visitor-trends', { headers })
.then(res => setVisitorTrends(res.data))
.catch(err => console.error('Failed to fetch trends:', err))
// Fetch visit purpose breakdown for pie chart
axios.get('http://localhost:8080/api/admin/visit-purposes', { headers })
.then(res => setPurposeData(res.data))
.catch(err => console.error('Failed to fetch purposes:', err))
// Fetch peak hours data for bar chart
axios.get('http://localhost:8080/api/admin/peak-hours', { headers })
.then(res => setPeakHoursData(res.data))
.catch(err => console.error('Failed to fetch peak hours:', err))
}, [])
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="font-bold text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Visitors Today</p>
<p className="text-3xl font-bold text-blue-600">{stats.visitorsToday}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Pending Approvals</p>
<p className="text-3xl font-bold text-yellow-600">{stats.pendingApprovals}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Total Homeowners</p>
<p className="text-3xl font-bold text-green-600">{stats.totalHomeowners}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<p className="text-sm text-gray-500">Pending Complaints</p>
<p className="text-3xl font-bold text-red-600">{stats.pendingComplaints}</p>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Visitor Trends Line Chart */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Visitor Trends</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={visitorTrends}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="visitors" stroke="#0088FE" name="Visitors" />
</LineChart>
</ResponsiveContainer>
</div>
{/* Visit Purposes Pie Chart */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Visit Purposes</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={purposeData}
dataKey="count"
nameKey="purpose"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{purposeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
{/* Peak Hours Bar Chart */}
<div className="bg-white p-6 rounded-lg shadow lg:col-span-2">
<h2 className="text-lg font-semibold mb-4">Peak Hours</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={peakHoursData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Bar dataKey="visitors" fill="#8884d8" name="Visitors" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
}
export default DashboardPage
import { useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function HomeownersPage() {
const [searchQuery, setSearchQuery] = useState('')
const [homeowners, setHomeowners] = useState([])
const [selectedHomeowner, setSelectedHomeowner] = useState(null)
// Search homeowners by name or block/lot/phase
const handleSearch = async () => {
const token = localStorage.getItem('token')
try {
const response = await axios.get(
`http://localhost:8080/api/homeowners/search?query=${searchQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setHomeowners(response.data)
} catch (err) {
console.error('Search failed:', err)
}
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="font-bold text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Homeowners</h1>
{/* Search Bar */}
<div className="flex gap-2 mb-6">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name, block, lot, or phase..."
className="flex-1 border border-gray-300 rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={handleSearch}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Search
</button>
</div>
{/* Results Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Block/Lot</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Phase</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Contact</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody>
{homeowners.map((ho) => (
<tr key={ho.id} className="border-t">
<td className="px-4 py-3">{ho.firstName} {ho.lastName}</td>
<td className="px-4 py-3">Blk {ho.block} Lot {ho.lot}</td>
<td className="px-4 py-3">{ho.phase}</td>
<td className="px-4 py-3">{ho.contactNumber}</td>
<td className="px-4 py-3">
<button
onClick={() => setSelectedHomeowner(ho)}
className="text-blue-600 hover:underline"
>
View Details
</button>
</td>
</tr>
))}
</tbody>
</table>
{homeowners.length === 0 && (
<p className="p-4 text-gray-500 text-center">No homeowners found. Try a search above.</p>
)}
</div>
{/* Detail Modal */}
{selectedHomeowner && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-lg w-96">
<h2 className="text-xl font-bold mb-4">
{selectedHomeowner.firstName} {selectedHomeowner.lastName}
</h2>
<p className="mb-2"><span className="font-medium">Block:</span> {selectedHomeowner.block}</p>
<p className="mb-2"><span className="font-medium">Lot:</span> {selectedHomeowner.lot}</p>
<p className="mb-2"><span className="font-medium">Phase:</span> {selectedHomeowner.phase}</p>
<p className="mb-2"><span className="font-medium">Contact:</span> {selectedHomeowner.contactNumber}</p>
<p className="mb-4"><span className="font-medium">Email:</span> {selectedHomeowner.email}</p>
<button
onClick={() => setSelectedHomeowner(null)}
className="w-full bg-gray-200 py-2 rounded hover:bg-gray-300"
>
Close
</button>
</div>
</div>
)}
</div>
)
}
export default HomeownersPage
import { useState } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function VisitorsPage() {
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [visitLogs, setVisitLogs] = useState([])
// Fetch visit logs filtered by date range
const handleFilter = async () => {
const token = localStorage.getItem('token')
try {
const response = await axios.get(
`http://localhost:8080/api/visit-logs?startDate=${startDate}&endDate=${endDate}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setVisitLogs(response.data)
} catch (err) {
console.error('Failed to fetch visit logs:', err)
}
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="font-bold text-blue-600">Visitors</Link>
<Link to="/complaints" className="text-gray-600 hover:text-blue-600">Complaints</Link>
</nav>
<h1 className="text-3xl font-bold mb-6">Visitor Logs</h1>
{/* Date Range Filter */}
<div className="flex gap-4 mb-6 items-end">
<div>
<label className="block text-sm font-medium mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="border border-gray-300 rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="border border-gray-300 rounded px-3 py-2"
/>
</div>
<button
onClick={handleFilter}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Filter
</button>
</div>
{/* Visit Logs Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Date/Time</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Visitor</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Homeowner</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Purpose</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
{visitLogs.map((log) => (
<tr key={log.id} className="border-t">
<td className="px-4 py-3">{new Date(log.timestamp).toLocaleString()}</td>
<td className="px-4 py-3">{log.visitorName}</td>
<td className="px-4 py-3">{log.homeownerName}</td>
<td className="px-4 py-3">{log.purpose}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${
log.status === 'APPROVED' ? 'bg-green-100 text-green-800' :
log.status === 'DENIED' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{log.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{visitLogs.length === 0 && (
<p className="p-4 text-gray-500 text-center">No visit logs found. Select a date range and click Filter.</p>
)}
</div>
</div>
)
}
export default VisitorsPage
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
function ComplaintsPage() {
const [complaints, setComplaints] = useState([])
const [aiSummary, setAiSummary] = useState('')
// Fetch all complaints on page load
useEffect(() => {
const token = localStorage.getItem('token')
axios.get('http://localhost:8080/api/complaints', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => setComplaints(res.data))
.catch(err => console.error('Failed to fetch complaints:', err))
}, [])
// Update a complaint's status (pending, reviewed, resolved)
const updateStatus = async (id, newStatus) => {
const token = localStorage.getItem('token')
try {
await axios.put(
`http://localhost:8080/api/complaints/${id}/status`,
{ status: newStatus },
{ headers: { Authorization: `Bearer ${token}` } }
)
// Update local state to reflect the change
setComplaints(complaints.map(c =>
c.id === id ? { ...c, status: newStatus } : c
))
} catch (err) {
console.error('Failed to update status:', err)
}
}
// Placeholder for AI summary generation (wired in a later step)
const generateAiSummary = () => {
setAiSummary('AI summary generation will be connected in the AI integration step.')
}
return (
<div className="min-h-screen bg-gray-100 p-6">
{/* Navigation */}
<nav className="mb-6 flex gap-4">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600">Dashboard</Link>
<Link to="/homeowners" className="text-gray-600 hover:text-blue-600">Homeowners</Link>
<Link to="/visitors" className="text-gray-600 hover:text-blue-600">Visitors</Link>
<Link to="/complaints" className="font-bold text-blue-600">Complaints</Link>
</nav>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Complaints & Suggestions</h1>
<button
onClick={generateAiSummary}
className="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"
>
Generate AI Summary
</button>
</div>
{/* AI Summary Display */}
{aiSummary && (
<div className="bg-purple-50 border border-purple-200 p-4 rounded-lg mb-6">
<h3 className="font-semibold text-purple-800 mb-2">AI Summary</h3>
<p className="text-purple-700">{aiSummary}</p>
</div>
)}
{/* Complaints List */}
<div className="space-y-4">
{complaints.map((complaint) => (
<div key={complaint.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{complaint.subject}</p>
<p className="text-sm text-gray-500 mt-1">
By {complaint.homeownerName} on {new Date(complaint.createdAt).toLocaleDateString()}
</p>
<p className="mt-2 text-gray-700">{complaint.description}</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
complaint.status === 'RESOLVED' ? 'bg-green-100 text-green-800' :
complaint.status === 'REVIEWED' ? 'bg-blue-100 text-blue-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{complaint.status}
</span>
</div>
{/* Status Update Buttons */}
<div className="mt-3 flex gap-2">
<button
onClick={() => updateStatus(complaint.id, 'REVIEWED')}
className="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded hover:bg-blue-200"
>
Mark Reviewed
</button>
<button
onClick={() => updateStatus(complaint.id, 'RESOLVED')}
className="text-sm bg-green-100 text-green-700 px-3 py-1 rounded hover:bg-green-200"
>
Mark Resolved
</button>
</div>
</div>
))}
{complaints.length === 0 && (
<p className="text-gray-500 text-center">No complaints found.</p>
)}
</div>
</div>
)
}
export default ComplaintsPage
Your admin dashboard is live. Next up, you'll connect Ollama and Llama 3.2 to power the AI summary features and generate intelligent reports from your visitor and complaint data.
Integrate AI with Ollama and Llama 3.2
Your admin dashboard has charts and data tables, but administrators still need to manually read through complaints and visitor logs to spot patterns.
In this step, you will build an AI integration layer that connects your Spring Boot backend to Ollama running locally. The backend sends data to Llama 3.2 and receives natural language summaries, reports, and insights that display directly on the admin dashboard.
In this step, get ready to:
- Create an AiService that communicates with the Ollama REST API.
- Build an AiController with three AI-powered endpoints.
- Wire the admin dashboard to display AI-generated summaries and insights.
Create the AiService class
The AiService encapsulates all communication with Ollama. It uses RestTemplate to POST chat messages to Ollama's /api/chat endpoint and extracts the assistant's response.
- In IntelliJ, create a new file called AiService.java inside your service package (the same folder as your other service classes).
- Paste the following code into the file:
package com.gateguard.service;
import com.gateguard.model.Complaint;
import com.gateguard.model.VisitLog;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class AiService {
// Ollama REST API endpoint for chat completions
private static final String OLLAMA_URL = "http://localhost:11434/api/chat";
private static final String MODEL = "llama3.2";
private final RestTemplate restTemplate;
public AiService() {
this.restTemplate = new RestTemplate();
}
/**
* Summarizes a list of complaints into a concise report.
*/
public String summarizeComplaints(List<Complaint> complaints) {
// Build a text block from all complaint descriptions
String complaintText = complaints.stream()
.map(c -> "- " + c.getDescription())
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a community administrator assistant. "
+ "Summarize the following homeowner complaints into key themes, "
+ "common issues, and recommended actions. Be concise.";
String userMessage = "Here are the complaints:\n" + complaintText;
return callOllama(systemPrompt, userMessage);
}
/**
* Generates a natural language report of visitor patterns for a date range.
*/
public String generateVisitorReport(LocalDate start, LocalDate end, List<VisitLog> visitLogs) {
// Build a summary of visit data for the LLM
String visitData = visitLogs.stream()
.map(v -> String.format("- %s visited %s on %s (purpose: %s)",
v.getVisitor().getName(),
v.getHomeowner().getLastName(),
v.getTimestamp().toLocalDate(),
v.getPurpose()))
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a security analytics assistant. "
+ "Analyze the following visitor logs and provide a report covering: "
+ "total visits, peak days, most common purposes, and any unusual patterns.";
String userMessage = String.format("Visitor logs from %s to %s:\n%s", start, end, visitData);
return callOllama(systemPrompt, userMessage);
}
/**
* Analyzes visit patterns and returns actionable insights.
*/
public String analyzePatterns(List<VisitLog> visitLogs) {
String visitSummary = visitLogs.stream()
.map(v -> String.format("- %s | %s | %s",
v.getTimestamp().toLocalDate(),
v.getPurpose(),
v.getStatus()))
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a subdivision management AI. "
+ "Based on recent visitor activity, provide 3-5 actionable insights "
+ "for the admin team. Focus on security recommendations, "
+ "staffing suggestions, and community trends.";
String userMessage = "Recent visit activity:\n" + visitSummary;
return callOllama(systemPrompt, userMessage);
}
/**
* Sends a chat request to Ollama and extracts the assistant response.
*/
private String callOllama(String systemPrompt, String userMessage) {
// Build the messages array with system and user roles
List<Map<String, String>> messages = List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userMessage)
);
// Build the request body matching Ollama's /api/chat format
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", MODEL);
requestBody.put("messages", messages);
requestBody.put("stream", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<Map> response = restTemplate.exchange(
OLLAMA_URL, HttpMethod.POST, entity, Map.class);
// Extract the assistant's content from the response
Map<String, Object> body = response.getBody();
if (body != null && body.containsKey("message")) {
Map<String, String> message = (Map<String, String>) body.get("message");
return message.get("content");
}
return "No response from AI model.";
} catch (Exception e) {
return "AI service unavailable: " + e.getMessage();
}
}
}
What does this code do?
The callOllama method is the core of this service. It builds a JSON request with a system message (instructions for the AI) and a user message (the actual data to analyze).
Setting "stream": false tells Ollama to return a single complete JSON response instead of streaming tokens one by one. The response contains a message.content field with the assistant's full reply.
Each public method (summarizeComplaints, generateVisitorReport, analyzePatterns) formats your database entities into plain text the LLM can understand, then passes a tailored system prompt for that specific task.
- Save AiService.java.
Seeing import errors?
Make sure your package declaration matches your project structure. If your base package is different from com.gateguard, adjust the package line accordingly. Also confirm that Complaint and VisitLog entity classes exist in your model package from Step 3.
help me fix import errors in my AiService
Create the AiController
The controller exposes three POST endpoints that fetch data from your database, pass it to the AiService, and return the AI-generated text to the frontend.
- Create a new file called AiController.java in your controller package.
- Paste the following code:
package com.gateguard.controller;
import com.gateguard.model.Complaint;
import com.gateguard.model.VisitLog;
import com.gateguard.repository.ComplaintRepository;
import com.gateguard.repository.VisitLogRepository;
import com.gateguard.service.AiService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/api/ai")
@PreAuthorize("hasRole('ADMIN')")
public class AiController {
private final AiService aiService;
private final ComplaintRepository complaintRepository;
private final VisitLogRepository visitLogRepository;
public AiController(AiService aiService,
ComplaintRepository complaintRepository,
VisitLogRepository visitLogRepository) {
this.aiService = aiService;
this.complaintRepository = complaintRepository;
this.visitLogRepository = visitLogRepository;
}
/**
* Summarizes complaints within a date range.
* Body: {"startDate": "2026-05-01", "endDate": "2026-05-11"}
*/
@PostMapping("/summarize-complaints")
public ResponseEntity<Map<String, String>> summarizeComplaints(
@RequestBody Map<String, String> body) {
LocalDate start = LocalDate.parse(body.get("startDate"));
LocalDate end = LocalDate.parse(body.get("endDate"));
// Fetch complaints within the date range
List<Complaint> complaints = complaintRepository
.findByCreatedAtBetween(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay());
String summary = aiService.summarizeComplaints(complaints);
return ResponseEntity.ok(Map.of("summary", summary));
}
/**
* Generates a natural language visitor report for a date range.
* Body: {"startDate": "2026-05-01", "endDate": "2026-05-11"}
*/
@PostMapping("/visitor-report")
public ResponseEntity<Map<String, String>> visitorReport(
@RequestBody Map<String, String> body) {
LocalDate start = LocalDate.parse(body.get("startDate"));
LocalDate end = LocalDate.parse(body.get("endDate"));
List<VisitLog> visitLogs = visitLogRepository
.findByTimestampBetween(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay());
String report = aiService.generateVisitorReport(start, end, visitLogs);
return ResponseEntity.ok(Map.of("report", report));
}
/**
* Generates actionable dashboard insights from the last 7 days.
*/
@PostMapping("/dashboard-insights")
public ResponseEntity<Map<String, String>> dashboardInsights() {
// Fetch last 7 days of visit logs
LocalDateTime weekAgo = LocalDateTime.now().minusDays(7);
List<VisitLog> recentLogs = visitLogRepository
.findByTimestampAfter(weekAgo);
String insights = aiService.analyzePatterns(recentLogs);
return ResponseEntity.ok(Map.of("insights", insights));
}
}
What does this code do?
- POST /api/ai/summarize-complaints accepts a date range, fetches matching complaints from the database, and returns an AI-generated summary.
- POST /api/ai/visitor-report does the same for visitor logs, producing a natural language report of patterns and trends.
- POST /api/ai/dashboard-insights automatically pulls the last 7 days of data and generates actionable insights without needing date parameters.
- The @PreAuthorize("hasRole('ADMIN')") annotation ensures only admin users can access these endpoints.
- Save AiController.java.
- Restart your Spring Boot application to pick up the new classes.
Why use RestTemplate instead of Spring AI?
Spring AI provides a higher-level abstraction for LLM communication, but it adds another dependency and configuration layer. Using RestTemplate gives you full visibility into the exact JSON being sent to Ollama.
This approach also teaches you how any HTTP-based AI service works under the hood. You can swap Ollama for any OpenAI-compatible API by changing the URL and model name.
- Test the summarize-complaints endpoint by running this curl command in your terminal:
curl -X POST http://localhost:8080/api/ai/summarize-complaints \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-admin-jwt-token-here" \
-d '{"startDate": "2026-01-01", "endDate": "2026-05-11"}'
What should I see?
If you have complaints in your database, you will see a JSON response like {"summary": "Based on the complaints received..."} with a natural language summary generated by Llama 3.2. If no complaints exist yet, the summary will indicate there was nothing to analyze.
The first request may take 10-30 seconds as Ollama loads the model into memory. Subsequent requests are much faster.
Getting a connection refused error?
Make sure Ollama is running. On macOS, check your menu bar for the Ollama icon. If it is not there, open a terminal and run ollama serve.
Verify Ollama is responding by running:
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{"role": "user", "content": "hello"}],
"stream": false
}'
help me debug my Ollama connection from Spring Boot
Wire the admin dashboard to display AI results
Your admin dashboard already has a Generate AI Summary button on the complaints page. Now you will connect it to the backend and add an AI insights panel to the main dashboard page.
- In VS Code, open gateguard-admin/src/pages/ComplaintsPage.jsx.
- Find the Generate AI Summary button (the placeholder from the previous step).
- Add the following state and handler function to wire up the AI call:
// Add these state variables at the top of your component
const [aiSummary, setAiSummary] = useState('');
const [aiLoading, setAiLoading] = useState(false);
// Add this handler function
const handleGenerateSummary = async () => {
setAiLoading(true);
try {
const token = localStorage.getItem('token');
// Call the AI summarize endpoint with the selected date range
const response = await axios.post(
'http://localhost:8080/api/ai/summarize-complaints',
{
startDate: startDate || '2026-01-01',
endDate: endDate || new Date().toISOString().split('T')[0]
},
{ headers: { Authorization: `Bearer ${token}` } }
);
setAiSummary(response.data.summary);
} catch (error) {
setAiSummary('Failed to generate summary. Please try again.');
} finally {
setAiLoading(false);
}
};
What does this code do?
The handler sends the current date filter values to your /api/ai/summarize-complaints endpoint. While the AI processes the request, aiLoading shows a loading state on the button. The returned summary text gets stored in aiSummary state and rendered in the panel below.
- Update the Generate AI Summary button to call handleGenerateSummary and add the summary display panel below the complaints list:
// Update the button onClick
<button
onClick={handleGenerateSummary}
disabled={aiLoading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{aiLoading ? 'Generating...' : 'Generate AI Summary'}
</button>
{/* Add this panel below your complaints list */}
{aiSummary && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg border">
<h3 className="text-lg font-semibold mb-2">AI Summary</h3>
<p className="text-gray-700 whitespace-pre-wrap">{aiSummary}</p>
</div>
)}
- Save ComplaintsPage.jsx.
Now add an AI insights panel to the main dashboard that loads automatically.
- Open gateguard-admin/src/pages/DashboardPage.jsx.
- Add the following state, effect hook, and JSX panel:
// Add state for AI insights
const [insights, setInsights] = useState('');
const [insightsLoading, setInsightsLoading] = useState(true);
// Add this useEffect to fetch insights on page load
useEffect(() => {
const fetchInsights = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.post(
'http://localhost:8080/api/ai/dashboard-insights',
{},
{ headers: { Authorization: `Bearer ${token}` } }
);
setInsights(response.data.insights);
} catch (error) {
setInsights('Unable to load AI insights.');
} finally {
setInsightsLoading(false);
}
};
fetchInsights();
}, []);
// Add this JSX panel below your existing charts
<div className="mt-8 p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold mb-4">AI Insights</h2>
{insightsLoading ? (
<p className="text-gray-500">Generating insights from recent activity...</p>
) : (
<p className="text-gray-700 whitespace-pre-wrap">{insights}</p>
)}
</div>
What does this code do?
The useEffect hook fires once when the dashboard page loads. It calls /api/ai/dashboard-insights which analyzes the last 7 days of visitor activity. While Llama 3.2 processes the data, the panel shows a loading message. Once the response arrives, the insights appear as formatted text.
- Save DashboardPage.jsx.
- Open your browser and navigate to http://localhost:5173 (your Vite dev server).
- Log in as an admin user and check the dashboard page. You should see the AI Insights panel loading and then displaying a text summary.
- Navigate to the complaints page, select a date range, and click Generate AI Summary. A summary panel should appear below the list.
AI panel shows 'Unable to load AI insights'?
Check three things in order:
- Is Ollama running? Check for the Ollama icon in your macOS menu bar.
- Is your Spring Boot backend running on port 8080? Check the IntelliJ console for errors.
- Does your admin JWT token have the ADMIN role? The AI endpoints are restricted to admins only.
help me debug why my AI insights panel is not loading on the dashboard
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in both the Spring Boot project and the React admin dashboard.
ⓧ I'd like to double check the full code
Here is the complete AiService.java:
package com.gateguard.service;
import com.gateguard.model.Complaint;
import com.gateguard.model.VisitLog;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class AiService {
// Ollama REST API endpoint for chat completions
private static final String OLLAMA_URL = "http://localhost:11434/api/chat";
private static final String MODEL = "llama3.2";
private final RestTemplate restTemplate;
public AiService() {
this.restTemplate = new RestTemplate();
}
/**
* Summarizes a list of complaints into a concise report.
*/
public String summarizeComplaints(List<Complaint> complaints) {
String complaintText = complaints.stream()
.map(c -> "- " + c.getDescription())
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a community administrator assistant. "
+ "Summarize the following homeowner complaints into key themes, "
+ "common issues, and recommended actions. Be concise.";
String userMessage = "Here are the complaints:\n" + complaintText;
return callOllama(systemPrompt, userMessage);
}
/**
* Generates a natural language report of visitor patterns for a date range.
*/
public String generateVisitorReport(LocalDate start, LocalDate end, List<VisitLog> visitLogs) {
String visitData = visitLogs.stream()
.map(v -> String.format("- %s visited %s on %s (purpose: %s)",
v.getVisitor().getName(),
v.getHomeowner().getLastName(),
v.getTimestamp().toLocalDate(),
v.getPurpose()))
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a security analytics assistant. "
+ "Analyze the following visitor logs and provide a report covering: "
+ "total visits, peak days, most common purposes, and any unusual patterns.";
String userMessage = String.format("Visitor logs from %s to %s:\n%s", start, end, visitData);
return callOllama(systemPrompt, userMessage);
}
/**
* Analyzes visit patterns and returns actionable insights.
*/
public String analyzePatterns(List<VisitLog> visitLogs) {
String visitSummary = visitLogs.stream()
.map(v -> String.format("- %s | %s | %s",
v.getTimestamp().toLocalDate(),
v.getPurpose(),
v.getStatus()))
.collect(Collectors.joining("\n"));
String systemPrompt = "You are a subdivision management AI. "
+ "Based on recent visitor activity, provide 3-5 actionable insights "
+ "for the admin team. Focus on security recommendations, "
+ "staffing suggestions, and community trends.";
String userMessage = "Recent visit activity:\n" + visitSummary;
return callOllama(systemPrompt, userMessage);
}
/**
* Sends a chat request to Ollama and extracts the assistant response.
*/
private String callOllama(String systemPrompt, String userMessage) {
List<Map<String, String>> messages = List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userMessage)
);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", MODEL);
requestBody.put("messages", messages);
requestBody.put("stream", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<Map> response = restTemplate.exchange(
OLLAMA_URL, HttpMethod.POST, entity, Map.class);
Map<String, Object> body = response.getBody();
if (body != null && body.containsKey("message")) {
Map<String, String> message = (Map<String, String>) body.get("message");
return message.get("content");
}
return "No response from AI model.";
} catch (Exception e) {
return "AI service unavailable: " + e.getMessage();
}
}
}
And here is the complete AiController.java:
package com.gateguard.controller;
import com.gateguard.model.Complaint;
import com.gateguard.model.VisitLog;
import com.gateguard.repository.ComplaintRepository;
import com.gateguard.repository.VisitLogRepository;
import com.gateguard.service.AiService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/api/ai")
@PreAuthorize("hasRole('ADMIN')")
public class AiController {
private final AiService aiService;
private final ComplaintRepository complaintRepository;
private final VisitLogRepository visitLogRepository;
public AiController(AiService aiService,
ComplaintRepository complaintRepository,
VisitLogRepository visitLogRepository) {
this.aiService = aiService;
this.complaintRepository = complaintRepository;
this.visitLogRepository = visitLogRepository;
}
@PostMapping("/summarize-complaints")
public ResponseEntity<Map<String, String>> summarizeComplaints(
@RequestBody Map<String, String> body) {
LocalDate start = LocalDate.parse(body.get("startDate"));
LocalDate end = LocalDate.parse(body.get("endDate"));
List<Complaint> complaints = complaintRepository
.findByCreatedAtBetween(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay());
String summary = aiService.summarizeComplaints(complaints);
return ResponseEntity.ok(Map.of("summary", summary));
}
@PostMapping("/visitor-report")
public ResponseEntity<Map<String, String>> visitorReport(
@RequestBody Map<String, String> body) {
LocalDate start = LocalDate.parse(body.get("startDate"));
LocalDate end = LocalDate.parse(body.get("endDate"));
List<VisitLog> visitLogs = visitLogRepository
.findByTimestampBetween(
start.atStartOfDay(),
end.plusDays(1).atStartOfDay());
String report = aiService.generateVisitorReport(start, end, visitLogs);
return ResponseEntity.ok(Map.of("report", report));
}
@PostMapping("/dashboard-insights")
public ResponseEntity<Map<String, String>> dashboardInsights() {
LocalDateTime weekAgo = LocalDateTime.now().minusDays(7);
List<VisitLog> recentLogs = visitLogRepository
.findByTimestampAfter(weekAgo);
String insights = aiService.analyzePatterns(recentLogs);
return ResponseEntity.ok(Map.of("insights", insights));
}
}
Your GateGuard platform now has AI-powered intelligence. Next up, you will integrate Stripe to process HOA dues payments from homeowners.
Add Stripe Payment Processing
Your homeowners can see their dues balance and payment history, but the Pay Now button does nothing yet.
In this step, you will integrate Stripe to process HOA dues payments.
Stripe Checkout handles the entire payment form on Stripe's servers, so GateGuard never touches raw card numbers.
In this step, get ready to:
- Add the Stripe Java SDK and configure test API keys in Spring Boot.
- Create PaymentService and PaymentController with a Checkout Session endpoint and webhook handler.
- Wire the mobile app Payments screen to open Stripe Checkout and confirm payment on return.
Add the Stripe SDK and configure test keys
The Stripe Java SDK provides classes for creating Checkout Sessions and verifying webhook signatures.
- Open pom.xml in IntelliJ and add the Stripe dependency inside the <dependencies> block:
<!-- Stripe payment processing -->
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>25.6.0</version>
</dependency>
What does this dependency do?
The stripe-java library gives you typed Java classes like Session, SessionCreateParams, and Webhook that wrap Stripe's REST API.
Version 25.6.0 supports the Checkout Session API you will use next.
- Reload Maven dependencies by clicking the Maven refresh icon in IntelliJ (or press Cmd+Shift+I on macOS).
Now add your Stripe test keys to the application configuration.
- Open src/main/resources/application.yml and add the following Stripe configuration block:
# Stripe payment configuration
stripe:
api:
key: your-stripe-secret-key-here
webhook:
secret: your-stripe-webhook-secret-here
Where do I find these keys?
Your secret key starts with sk_test_ and lives in your Stripe Dashboard under Developers > API keys.
The webhook secret starts with whsec_ and is shown when you create a webhook endpoint (you will do this after deploying the controller).
- Replace your-stripe-secret-key-here with your actual sk_test_ key from the Stripe Dashboard.
Next, create a configuration class that initializes the Stripe SDK with your key.
- Create a new file src/main/java/.../config/StripeConfig.java and add the following:
package com.gateguard.config;
import com.stripe.Stripe;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StripeConfig {
@Value("${stripe.api.key}")
private String stripeApiKey;
// Initialize Stripe SDK with the API key when the app starts
@PostConstruct
public void init() {
Stripe.apiKey = stripeApiKey;
}
}
What does this code do?
The @PostConstruct method runs once when the Spring application starts.
It sets Stripe.apiKey globally so every Stripe API call in your app uses the correct credentials without passing the key around manually.
- Save both pom.xml and application.yml.
- Restart your Spring Boot application.
- Check the console output for a clean startup with no errors related to Stripe.
Application fails to start?
If you see a dependency resolution error, confirm the <version>25.6.0</version> tag is inside the <dependency> block and that you refreshed Maven.
If the error mentions stripe.api.key not found, confirm your YAML indentation is correct (two spaces per level).
Help me debug my Stripe SDK setup in Spring Boot.
Create PaymentService and PaymentController
The service layer handles Stripe API calls and payment persistence.
The controller exposes two endpoints: one for the mobile app to request a Checkout Session, and one for Stripe to send payment confirmation webhooks.
- Create src/main/java/.../service/PaymentService.java with the following code:
package com.gateguard.service;
import com.gateguard.entity.Payment;
import com.gateguard.repository.PaymentRepository;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.Event;
import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.checkout.SessionCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
@Value("${stripe.webhook.secret}")
private String webhookSecret;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
// Creates a Stripe Checkout Session and saves a PENDING payment record
public String createCheckoutSession(Long homeownerId, BigDecimal amount) throws Exception {
// Build session params with line item for HOA dues
SessionCreateParams params = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.PAYMENT)
.setSuccessUrl("http://localhost:8081/payment-success")
.setCancelUrl("http://localhost:8081/payment-cancel")
.addLineItem(
SessionCreateParams.LineItem.builder()
.setQuantity(1L)
.setPriceData(
SessionCreateParams.LineItem.PriceData.builder()
.setCurrency("usd")
.setUnitAmount(amount.multiply(BigDecimal.valueOf(100)).longValue())
.setProductData(
SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName("HOA Dues Payment")
.build()
)
.build()
)
.build()
)
.build();
// Create the session via Stripe API
Session session = Session.create(params);
// Save payment record with PENDING status
Payment payment = new Payment();
payment.setHomeownerId(homeownerId);
payment.setAmount(amount);
payment.setStripeSessionId(session.getId());
payment.setStatus("PENDING");
paymentRepository.save(payment);
return session.getUrl();
}
// Verifies webhook signature and updates payment status on success
public void handleWebhookEvent(String payload, String sigHeader) {
try {
// Verify the webhook signature to confirm it came from Stripe
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
// Process checkout.session.completed events
if ("checkout.session.completed".equals(event.getType())) {
Session session = (Session) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (session != null) {
// Find the payment by Stripe session ID and mark as COMPLETED
paymentRepository.findByStripeSessionId(session.getId())
.ifPresent(payment -> {
payment.setStatus("COMPLETED");
paymentRepository.save(payment);
});
}
}
} catch (SignatureVerificationException e) {
throw new RuntimeException("Invalid webhook signature", e);
}
}
}
What does this code do?
- createCheckoutSession builds a Stripe Checkout Session with a line item for the HOA dues amount, saves a PENDING payment record to the database, and returns the session URL.
- setUnitAmount expects cents, so the dollar amount is multiplied by 100.
- handleWebhookEvent uses Webhook.constructEvent to verify the request came from Stripe, then updates the payment status to COMPLETED.
- Save PaymentService.java.
Now create the controller that exposes these endpoints to the mobile app and to Stripe's webhook system.
- Create src/main/java/.../controller/PaymentController.java with the following code:
package com.gateguard.controller;
import com.gateguard.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
// Mobile app calls this to get a Stripe Checkout URL
@PostMapping("/payments/create-session")
public ResponseEntity<?> createCheckoutSession(@RequestBody Map<String, Object> request) {
try {
Long homeownerId = Long.valueOf(request.get("homeownerId").toString());
BigDecimal amount = new BigDecimal(request.get("amount").toString());
String sessionUrl = paymentService.createCheckoutSession(homeownerId, amount);
return ResponseEntity.ok(Map.of("sessionUrl", sessionUrl));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
// Stripe sends payment events here after checkout completes
@PostMapping("/webhook/stripe")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
try {
paymentService.handleWebhookEvent(payload, sigHeader);
return ResponseEntity.ok("OK");
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
What does this code do?
- POST /api/payments/create-session accepts a JSON body with homeownerId and amount, then returns the Stripe Checkout URL.
- POST /api/webhook/stripe receives the raw request body and the Stripe-Signature header. It passes both to the service for signature verification.
- The webhook endpoint must be excluded from Spring Security's CSRF protection and JWT filter because Stripe sends unauthenticated requests.
Exclude the webhook from Spring Security
In your Security filter chain configuration, add .requestMatchers("/api/webhook/stripe").permitAll() to ensure Stripe can reach the endpoint without a JWT token.
- Save PaymentController.java.
- Restart your Spring Boot application and confirm it starts without errors.
Test the create-session endpoint with Postman to verify it returns a Stripe Checkout URL.
- In Postman, send a POST request to http://localhost:8080/api/payments/create-session with this JSON body:
{
"homeownerId": 1,
"amount": 150.00
}
- Include your JWT token in the Authorization header as Bearer <your-token>.
- You should receive a 200 response with a sessionUrl field containing a URL like https://checkout.stripe.com/c/pay/....
Getting a 401 or 403 error?
Confirm you included a valid JWT Bearer token. If the token is expired, log in again via POST /api/auth/login to get a fresh token.
If you see a Stripe-related error like Invalid API Key provided, double-check the key in application.yml starts with sk_test_.
Help me debug my Stripe create-session endpoint.
Wire the mobile app Payments screen to Stripe Checkout
The mobile app needs to call your backend, receive the Checkout URL, and open it in an in-app browser so the homeowner can pay without leaving the app.
- Install the expo-web-browser package in your GateGuardMobile project by running:
npx expo install expo-web-browser
What is expo-web-browser?
This package opens URLs in a system browser modal (Safari on iOS, Chrome Custom Tabs on Android) without ejecting from Expo.
When the user finishes on Stripe's page and taps back, control returns to your app automatically.
- Open GateGuardMobile/screens/PaymentsScreen.js and replace the existing placeholder code with:
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
export default function PaymentsScreen() {
const { user, token } = useAuth();
const [payments, setPayments] = useState([]);
const [loading, setLoading] = useState(false);
// Fetch payment history for this homeowner
const fetchPayments = async () => {
try {
const response = await axios.get(
`http://localhost:8080/api/payments/homeowner/${user.homeownerId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
setPayments(response.data);
} catch (error) {
console.error('Failed to fetch payments:', error);
}
};
useEffect(() => {
fetchPayments();
}, []);
// Create a Stripe Checkout Session and open it in the browser
const handlePayNow = async () => {
setLoading(true);
try {
const response = await axios.post(
'http://localhost:8080/api/payments/create-session',
{ homeownerId: user.homeownerId, amount: 150.00 },
{ headers: { Authorization: `Bearer ${token}` } }
);
// Open the Stripe Checkout page in an in-app browser
await WebBrowser.openBrowserAsync(response.data.sessionUrl);
// Refresh payment history after returning from checkout
fetchPayments();
} catch (error) {
Alert.alert('Payment Error', 'Could not start payment. Please try again.');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>HOA Dues</Text>
<Text style={styles.balance}>Current Balance: $150.00</Text>
<TouchableOpacity
style={styles.payButton}
onPress={handlePayNow}
disabled={loading}
>
<Text style={styles.payButtonText}>
{loading ? 'Processing...' : 'Pay Now'}
</Text>
</TouchableOpacity>
<Text style={styles.historyTitle}>Payment History</Text>
<FlatList
data={payments}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.paymentItem}>
<Text>${item.amount} - {item.status}</Text>
<Text style={styles.date}>{item.createdAt}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#fff' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 10 },
balance: { fontSize: 18, marginBottom: 20, color: '#333' },
payButton: { backgroundColor: '#6772e5', padding: 16, borderRadius: 8, alignItems: 'center' },
payButtonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' },
historyTitle: { fontSize: 18, fontWeight: 'bold', marginTop: 30, marginBottom: 10 },
paymentItem: { padding: 12, borderBottomWidth: 1, borderBottomColor: '#eee' },
date: { color: '#888', marginTop: 4 },
});
What does this code do?
- fetchPayments calls GET /api/payments/homeowner/{id} to display past payments.
- handlePayNow calls your backend to create a Checkout Session, then uses WebBrowser.openBrowserAsync to open the Stripe payment page in a modal browser.
- After the browser closes (user completes or cancels), fetchPayments() refreshes the list so the new payment appears once the webhook confirms it.
- Save PaymentsScreen.js.
- In your terminal running Expo, press r to reload the app.
Now test the full payment flow with Stripe's test card.
- Log in as a homeowner in the mobile app.
- Navigate to the Payments tab.
- Tap Pay Now.
- On the Stripe Checkout page, enter the test card number 4242 4242 4242 4242.
- Use any future expiration date (e.g. 12/30) and any 3-digit CVC.
- Complete the payment.
After completing checkout, you should be redirected back to the app and the payment history should refresh.
Why use 4242 4242 4242 4242?
This is Stripe's universal test card that always succeeds.
No real money is charged because your API key starts with sk_test_. Test and live modes run simultaneously in Stripe, determined entirely by which key you use.
Register your webhook in the Stripe Dashboard
For Stripe to notify your backend of completed payments, navigate to Developers > Webhooks in the Stripe Dashboard.
Add an endpoint URL pointing to your backend (e.g. your EC2 public IP or an ngrok tunnel for local testing) with the checkout.session.completed event selected. Copy the webhook signing secret (whsec_...) into your application.yml.
Payment screen not opening Stripe?
Confirm expo-web-browser installed successfully by checking your package.json for the dependency.
If the app crashes on tap, make sure you imported * as WebBrowser from 'expo-web-browser' at the top of the file.
If the API call fails with a network error, verify your Spring Boot server is running and accessible from the device (use your Mac's local IP instead of localhost if testing on a physical phone).
Help me debug my Stripe Checkout flow in React Native.
✔️ Awesome, I've got everything!
Great. Double-check you have saved all your files and that the Spring Boot server restarted cleanly.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
package com.gateguard.config;
import com.stripe.Stripe;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StripeConfig {
@Value("${stripe.api.key}")
private String stripeApiKey;
@PostConstruct
public void init() {
Stripe.apiKey = stripeApiKey;
}
}
package com.gateguard.service;
import com.gateguard.entity.Payment;
import com.gateguard.repository.PaymentRepository;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.Event;
import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.checkout.SessionCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
@Value("${stripe.webhook.secret}")
private String webhookSecret;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
public String createCheckoutSession(Long homeownerId, BigDecimal amount) throws Exception {
SessionCreateParams params = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.PAYMENT)
.setSuccessUrl("http://localhost:8081/payment-success")
.setCancelUrl("http://localhost:8081/payment-cancel")
.addLineItem(
SessionCreateParams.LineItem.builder()
.setQuantity(1L)
.setPriceData(
SessionCreateParams.LineItem.PriceData.builder()
.setCurrency("usd")
.setUnitAmount(amount.multiply(BigDecimal.valueOf(100)).longValue())
.setProductData(
SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName("HOA Dues Payment")
.build()
)
.build()
)
.build()
)
.build();
Session session = Session.create(params);
Payment payment = new Payment();
payment.setHomeownerId(homeownerId);
payment.setAmount(amount);
payment.setStripeSessionId(session.getId());
payment.setStatus("PENDING");
paymentRepository.save(payment);
return session.getUrl();
}
public void handleWebhookEvent(String payload, String sigHeader) {
try {
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
if ("checkout.session.completed".equals(event.getType())) {
Session session = (Session) event.getDataObjectDeserializer()
.getObject().orElse(null);
if (session != null) {
paymentRepository.findByStripeSessionId(session.getId())
.ifPresent(payment -> {
payment.setStatus("COMPLETED");
paymentRepository.save(payment);
});
}
}
} catch (SignatureVerificationException e) {
throw new RuntimeException("Invalid webhook signature", e);
}
}
}
package com.gateguard.controller;
import com.gateguard.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/payments/create-session")
public ResponseEntity<?> createCheckoutSession(@RequestBody Map<String, Object> request) {
try {
Long homeownerId = Long.valueOf(request.get("homeownerId").toString());
BigDecimal amount = new BigDecimal(request.get("amount").toString());
String sessionUrl = paymentService.createCheckoutSession(homeownerId, amount);
return ResponseEntity.ok(Map.of("sessionUrl", sessionUrl));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/webhook/stripe")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
try {
paymentService.handleWebhookEvent(payload, sigHeader);
return ResponseEntity.ok("OK");
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
export default function PaymentsScreen() {
const { user, token } = useAuth();
const [payments, setPayments] = useState([]);
const [loading, setLoading] = useState(false);
const fetchPayments = async () => {
try {
const response = await axios.get(
`http://localhost:8080/api/payments/homeowner/${user.homeownerId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
setPayments(response.data);
} catch (error) {
console.error('Failed to fetch payments:', error);
}
};
useEffect(() => {
fetchPayments();
}, []);
const handlePayNow = async () => {
setLoading(true);
try {
const response = await axios.post(
'http://localhost:8080/api/payments/create-session',
{ homeownerId: user.homeownerId, amount: 150.00 },
{ headers: { Authorization: `Bearer ${token}` } }
);
await WebBrowser.openBrowserAsync(response.data.sessionUrl);
fetchPayments();
} catch (error) {
Alert.alert('Payment Error', 'Could not start payment. Please try again.');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>HOA Dues</Text>
<Text style={styles.balance}>Current Balance: $150.00</Text>
<TouchableOpacity
style={styles.payButton}
onPress={handlePayNow}
disabled={loading}
>
<Text style={styles.payButtonText}>
{loading ? 'Processing...' : 'Pay Now'}
</Text>
</TouchableOpacity>
<Text style={styles.historyTitle}>Payment History</Text>
<FlatList
data={payments}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.paymentItem}>
<Text>${item.amount} - {item.status}</Text>
<Text style={styles.date}>{item.createdAt}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#fff' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 10 },
balance: { fontSize: 18, marginBottom: 20, color: '#333' },
payButton: { backgroundColor: '#6772e5', padding: 16, borderRadius: 8, alignItems: 'center' },
payButtonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' },
historyTitle: { fontSize: 18, fontWeight: 'bold', marginTop: 30, marginBottom: 10 },
paymentItem: { padding: 12, borderBottomWidth: 1, borderBottomColor: '#eee' },
date: { color: '#888', marginTop: 4 },
});
Your homeowners can now pay their HOA dues directly from the mobile app.
Next up, you will add system design patterns like Redis caching, rate limiting, and an API gateway to make GateGuard production-ready.
Implement Caching, Rate Limiting, and API Gateway
Your backend works, but right now every request hits the database directly, any user can hammer your endpoints without limit, and all traffic goes straight to individual services with no central routing. These are the exact problems that system design patterns solve in production architectures.
In this step, you will add three critical infrastructure layers to GateGuard. Redis caching reduces database load on frequently accessed endpoints. Rate limiting protects your API from abuse. An API Gateway gives you a single entry point that routes traffic to all your microservices.
In this step, get ready to:
- Add Redis caching to homeowner search and visitor statistics endpoints with TTL-based expiration.
- Implement a Redis-based rate limiting filter that restricts request frequency per endpoint.
- Set up Spring Cloud Gateway as a separate microservice to route all traffic through a single port.
Add Redis caching with Spring Cache abstraction
Every time a guard searches for a homeowner by block/lot/phase, the database runs the same query. Visitor statistics on the admin dashboard trigger expensive aggregation queries on every page load. Caching these results in Redis means the database only processes each unique query once per TTL window.
- Add the Redis caching dependencies to your pom.xml by pasting these inside the <dependencies> block:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
What do these dependencies do?
spring-boot-starter-cache enables Spring's cache abstraction so you can annotate methods with @Cacheable and @CacheEvict.
spring-boot-starter-data-redis connects Spring's caching layer to your Redis instance instead of using an in-memory map. This means cached data persists across application restarts and is shared if you ever scale to multiple instances.
- Create the cache configuration class at src/main/java/.../config/CacheConfig.java:
package com.gateguard.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Default cache config with 10-minute TTL and JSON serialization
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
);
// Per-cache TTL overrides
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// Homeowner search results expire after 5 minutes
cacheConfigurations.put("homeowner-search",
defaultConfig.entryTtl(Duration.ofMinutes(5)));
// Visitor statistics expire after 2 minutes (data changes frequently)
cacheConfigurations.put("visitor-stats",
defaultConfig.entryTtl(Duration.ofMinutes(2)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
What does this configuration do?
- @EnableCaching activates Spring's cache annotations across your project.
- The RedisCacheManager bean tells Spring to store cached values in Redis instead of memory.
- GenericJackson2JsonRedisSerializer stores objects as JSON in Redis so they are readable and debuggable.
- Each cache name gets its own TTL. Homeowner searches expire after 5 minutes. Visitor stats expire after 2 minutes since that data changes more frequently.
- Now add the caching annotations to your HomeownerService.java. Find your search method and add @Cacheable, then add @CacheEvict to your update and delete methods:
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
// Add @Cacheable to your search method
@Cacheable(value = "homeowner-search", key = "#query")
public List<Homeowner> searchHomeowners(String query) {
// Existing search logic using JPA Specification
// This only runs on cache miss
return homeownerRepository.findAll(buildSearchSpec(query));
}
// Add @CacheEvict to update - clears stale search results
@CacheEvict(value = "homeowner-search", allEntries = true)
public Homeowner updateHomeowner(Long id, Homeowner updated) {
// Existing update logic
return homeownerRepository.save(updated);
}
// Add @CacheEvict to delete - clears stale search results
@CacheEvict(value = "homeowner-search", allEntries = true)
public void deleteHomeowner(Long id) {
// Existing delete logic
homeownerRepository.deleteById(id);
}
How does this caching work?
@Cacheable checks Redis before running the method. If a cached result exists for that query key, it returns the cached value without hitting the database.
@CacheEvict clears all entries in the homeowner-search cache whenever a homeowner is updated or deleted. This prevents stale data from being served after mutations.
- Update your application.yml to configure Redis and change the server port to 8081 (the gateway will take port 8080 later):
server:
port: 8081
spring:
cache:
type: redis
data:
redis:
host: localhost
port: 6379
Why change the port to 8081?
Later in this step, you will set up Spring Cloud Gateway on port 8080. The gateway becomes the single entry point for all traffic. Your main backend moves to 8081 and the gateway forwards requests to it.
- Save all modified files.
- Restart your Spring Boot application and test the homeowner search endpoint twice with the same query. The second request should return noticeably faster.
You can verify caching is working by checking Redis directly.
- Confirm cached entries exist in Redis by running:
docker exec -it redis redis-cli keys "homeowner-search::*"
You should see one or more keys listed matching your search queries.
Not seeing any cached keys?
- Verify your Redis container is still running with docker ps. If it stopped, restart it.
- Check that spring.cache.type: redis is set in application.yml. Without this, Spring defaults to an in-memory cache.
- Make sure you actually called the search endpoint at least once after restarting the app.
help me debug why my Redis cache is not storing entries
Implement Redis-based rate limiting
Without rate limiting, a single client can flood your API with thousands of requests per second. This overloads your database, starves other users of resources, and makes brute-force attacks trivial.
You will build a filter that uses Redis atomic counters to track requests per client within a time window. When a client exceeds their limit, the filter returns HTTP 429 (Too Many Requests) before the request reaches your controller.
- Create the rate limiting filter at src/main/java/.../filter/RateLimitingFilter.java:
package com.gateguard.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.core.Authentication;
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.concurrent.TimeUnit;
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final StringRedisTemplate redisTemplate;
public RateLimitingFilter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
String clientKey = resolveClientKey(request, path);
int limit = resolveLimit(path);
int windowSeconds = 60;
// Use Redis INCR to atomically count requests
String redisKey = "rate_limit:" + clientKey;
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
// Set expiry on first request in the window
if (currentCount != null && currentCount == 1) {
redisTemplate.expire(redisKey, windowSeconds, TimeUnit.SECONDS);
}
// Block if limit exceeded
if (currentCount != null && currentCount > limit) {
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(windowSeconds));
response.getWriter().write("{\"error\": \"Too many requests. Try again later.\"}");
return;
}
filterChain.doFilter(request, response);
}
// Determine rate limit based on endpoint category
private int resolveLimit(String path) {
if (path.startsWith("/api/auth")) {
return 5; // 5 requests per minute for auth (prevents brute-force)
} else if (path.startsWith("/api/ocr")) {
return 10; // 10 requests per minute for OCR (resource-intensive)
} else if (path.startsWith("/api/ai")) {
return 3; // 3 requests per minute for AI (Ollama is slow)
}
return 60; // Default: 60 requests per minute for other endpoints
}
// Use IP for auth endpoints, user ID for authenticated endpoints
private String resolveClientKey(HttpServletRequest request, String path) {
if (path.startsWith("/api/auth")) {
return "ip:" + request.getRemoteAddr() + ":auth";
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
return "user:" + auth.getName() + ":" + getPathCategory(path);
}
return "ip:" + request.getRemoteAddr() + ":" + getPathCategory(path);
}
// Group paths into categories for rate limiting
private String getPathCategory(String path) {
if (path.startsWith("/api/ocr")) return "ocr";
if (path.startsWith("/api/ai")) return "ai";
return "general";
}
}
How does this rate limiter work?
- Redis INCR atomically increments a counter for each client+endpoint combination. This is safe even under concurrent requests.
- On the first request, EXPIRE sets a 60-second window. After 60 seconds, the key auto-deletes and the counter resets.
- Auth endpoints use the client IP as the key (since the user is not authenticated yet). Other endpoints use the authenticated user ID.
- The Retry-After header tells clients how long to wait before retrying.
- Save RateLimitingFilter.java.
- Restart your Spring Boot application and test rate limiting by running this command rapidly more than 5 times within one minute:
curl -X POST http://localhost:8081/api/auth/login -H "Content-Type: application/json" -d '{"username":"testuser","password":"testpass"}'
After the 5th request within 60 seconds, you should see a 429 response with the message "Too many requests. Try again later."
Not getting a 429 response?
- Make sure you are sending requests to port 8081 (the backend's new port).
- Check that the filter is being picked up by Spring. The @Component annotation must be present and the class must be in a package that Spring scans.
- Verify Redis is accessible by running docker exec -it redis redis-cli ping. You should see PONG.
help me debug why my rate limiting filter is not blocking requests after the limit
Set up Spring Cloud Gateway as a separate microservice
Right now, clients need to know the specific port for each service (8081 for the backend, 8001 for OCR). An API Gateway acts as a single front door that routes traffic to the correct service based on the request path.
Spring Cloud Gateway is a reactive, non-blocking gateway built on Spring WebFlux. It provides routing, filtering, and cross-cutting concerns like logging and circuit breaking.
- Create a new directory for the gateway microservice at the same level as your main backend project:
mkdir gateway
mkdir -p gateway/src/main/java/com/gateguard/gateway
mkdir -p gateway/src/main/resources
- Create the gateway's pom.xml inside the gateway folder:
<?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>3.5.11</version>
<relativePath/>
</parent>
<groupId>com.gateguard</groupId>
<artifactId>gateway</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>GateGuard Gateway</name>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
What does this pom.xml set up?
spring-cloud-starter-gateway brings in Spring Cloud Gateway with its reactive routing engine. The Spring Cloud BOM (2024.0.0) manages compatible versions of all Spring Cloud dependencies.
spring-boot-starter-actuator exposes health checks and metrics for monitoring the gateway itself.
- Create the gateway application entry point at gateway/src/main/java/com/gateguard/gateway/GatewayApplication.java:
package com.gateguard.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
- Create the gateway configuration at gateway/src/main/resources/application.yml:
server:
port: 8080
spring:
application:
name: gateguard-gateway
cloud:
gateway:
routes:
# Route auth requests to the backend
- id: auth-route
uri: http://localhost:8081
predicates:
- Path=/api/auth/**
# Route homeowner/visitor/payment requests to the backend
- id: backend-route
uri: http://localhost:8081
predicates:
- Path=/api/homeowners/**,/api/visitors/**,/api/visit-logs/**,/api/payments/**,/api/notifications/**
# Route OCR requests to the Python FastAPI service
- id: ocr-route
uri: http://localhost:8001
predicates:
- Path=/api/ocr/**
# Route AI requests to the backend AI controller
- id: ai-route
uri: http://localhost:8081
predicates:
- Path=/api/ai/**
# Route image requests to the backend
- id: images-route
uri: http://localhost:8081
predicates:
- Path=/api/images/**
# Global filters applied to all routes
default-filters:
- name: AddResponseHeader
args:
name: X-Gateway-Service
value: gateguard-gateway
# Actuator endpoints for gateway health monitoring
management:
endpoints:
web:
exposure:
include: health,gateway
logging:
level:
org.springframework.cloud.gateway: DEBUG
How does routing work?
- Each route has an id, a target uri, and predicates that match incoming request paths.
- When a request arrives at port 8080, the gateway checks each route's predicates. The first match wins and the request is forwarded to that route's URI.
- The X-Gateway-Service response header lets you confirm the request passed through the gateway.
- DEBUG logging on the gateway package shows you exactly which route matched for every request.
- Now create the load balancing documentation. Create LOAD_BALANCING_NOTES.md in your project root:
# Load Balancing and Horizontal Scaling Notes
## Current Architecture
- Spring Cloud Gateway on port 8080 (single entry point)
- Backend service on port 8081
- OCR service on port 8001
- All services are single-instance for development
## Horizontal Scaling Approach
To scale the backend horizontally:
1. Run multiple backend instances on different ports (8081, 8082, 8083)
2. Register instances with a service registry (Eureka or Consul)
3. Gateway uses `lb://gateguard-backend` URI with service discovery
4. Requests are distributed round-robin across healthy instances
## Why This Architecture Supports Scaling
- **Stateless JWT auth**: No server-side sessions, so any instance can
handle any request. The JWT token contains all identity information.
- **Redis shared cache**: All instances read/write to the same Redis,
so cache hits are consistent regardless of which instance serves.
- **RabbitMQ decoupling**: Notification producers and consumers scale
independently. Adding consumers increases notification throughput.
## Circuit Breaker Pattern
If the OCR service becomes slow or unresponsive:
1. Gateway detects consecutive failures (threshold: 5 in 10 seconds)
2. Circuit opens and immediately returns 503 without forwarding
3. After a cooldown period (30 seconds), circuit enters half-open state
4. If the next request succeeds, circuit closes and normal routing resumes
Implementation: Add `spring-cloud-starter-circuitbreaker-resilience4j`
to the gateway and configure per-route circuit breaker filters.
Why document instead of implement full load balancing?
Real load balancing requires a service registry like Eureka and multiple running instances. For a development environment, a single instance per service is appropriate.
The documentation captures the architectural decisions and shows you understand how to scale this system in production. The key insight is that your JWT-based auth and Redis shared cache already make the backend stateless and ready for horizontal scaling.
- Save all gateway files.
- Make sure your main backend is running on port 8081 and your OCR service is running on port 8001.
- Start the gateway by running these commands from the gateway directory:
cd gateway
./mvnw spring-boot:run
- Test that requests route correctly through the gateway by running:
curl -v http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"testuser","password":"testpass"}'
You should see the response include the X-Gateway-Service: gateguard-gateway header, confirming the request passed through the gateway before reaching your backend.
Gateway not routing requests?
- Confirm the backend is running on port 8081 (not 8080). Check your backend's application.yml has server.port: 8081.
- Check that port 8080 is not already in use. If something else is running on 8080, the gateway cannot start.
- Look at the gateway's console output for route registration messages. You should see all five routes listed at startup.
help me debug why my Spring Cloud Gateway is not forwarding requests to my backend service
Your GateGuard backend now has three production-grade infrastructure layers. Redis caching reduces database queries on repeated lookups. Rate limiting protects every endpoint from abuse. The API Gateway gives all clients a single port to talk to, regardless of which microservice handles the request. Next up, you will add monitoring with Prometheus and Grafana so you can observe how these layers perform in real time.
Set Up Monitoring with Prometheus and Grafana
Your backend now handles caching, rate limiting, and request routing through a gateway. But here is the problem: how do you know if any of it is actually working well under load?
Without monitoring, you are flying blind. In this step, you will add Prometheus to collect metrics from your Spring Boot backend and Grafana to visualize them in real-time dashboards. You will also create custom business metrics that track GateGuard-specific events like visitor check-ins and payment totals.
In this step, get ready to:
- Expose Spring Boot metrics via Actuator and Micrometer, then run Prometheus to scrape them.
- Set up Grafana with a dashboard showing request rate, response times, error rate, JVM memory, database connections, and cache hit rate.
- Add custom business metrics for visitor check-ins, pending approvals, payments, and OCR processing time.
Add Spring Boot Actuator and Prometheus metrics
Spring Boot Actuator exposes operational information about your running application. Micrometer is the metrics library that formats this data for Prometheus to consume.
- Open your Spring Boot backend's pom.xml in IntelliJ.
- Add these two dependencies inside the <dependencies> section:
<!-- Expose operational metrics and health endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Format metrics for Prometheus scraping -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
What do these dependencies do?
spring-boot-starter-actuator adds endpoints like /actuator/health and /actuator/metrics that report on your application's internal state.
micrometer-registry-prometheus translates those metrics into the text format that Prometheus expects at /actuator/prometheus.
Next, you need to tell Spring Boot which Actuator endpoints to expose over HTTP.
- Open src/main/resources/application.yml.
- Add the following management configuration at the root level (not nested under any existing key):
# Expose Actuator endpoints for monitoring
management:
endpoints:
web:
exposure:
include: prometheus,health,info,metrics
endpoint:
prometheus:
enabled: true
What does this configuration do?
exposure.include limits which Actuator endpoints are accessible via HTTP. By default, only health is exposed. You are adding prometheus, info, and metrics so Prometheus can scrape them.
- Save application.yml and restart your Spring Boot application.
- Verify the Prometheus endpoint works by opening your browser and navigating to:
curl http://localhost:8081/actuator/prometheus
You should see a wall of text with metric lines like jvm_memory_used_bytes and http_server_requests_seconds. This is the raw data Prometheus will collect.
Getting a 404 or empty response?
Make sure you added both dependencies to pom.xml and reloaded Maven (click the refresh icon in IntelliJ's Maven panel).
Check that your management block is at the root indentation level of application.yml, not nested under spring:.
Help me debug my Actuator Prometheus endpoint
Now you need to set up Prometheus to scrape this endpoint on a schedule.
- In your project root directory (the same folder that contains pom.xml), create a file called prometheus.yml with the following content:
# Prometheus configuration for scraping GateGuard metrics
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'gateguard-backend'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['host.docker.internal:8081']
What does this configuration do?
scrape_interval: 15s tells Prometheus to collect metrics every 15 seconds.
host.docker.internal:8081 is how a Docker container reaches your Mac's localhost. Since Prometheus runs inside Docker but your Spring Boot app runs on the host machine, you cannot use localhost directly.
- In the same project root, create docker-compose.monitoring.yml to run both Prometheus and Grafana:
# Docker Compose for monitoring stack
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
What does this docker-compose file do?
It runs two containers. Prometheus on port 9090 mounts your local prometheus.yml as its configuration. Grafana on port 3000 provides the visualization dashboard.
- Start the monitoring stack by running this command from your project root:
docker compose -f docker-compose.monitoring.yml up -d
- Verify Prometheus is scraping your backend by opening http://localhost:9090/targets in your browser.
You should see the gateguard-backend target with a green "UP" status. This means Prometheus is successfully collecting metrics from your Spring Boot app every 15 seconds.
Target showing as DOWN?
Make sure your Spring Boot application is running on port 8081. The Prometheus container needs to reach the host machine via host.docker.internal.
Click the error link next to the target in the Prometheus UI to see the specific connection error message.
Help me fix my Prometheus target connection
Create a Grafana dashboard for GateGuard
Prometheus collects metrics but its built-in UI is minimal. Grafana turns those numbers into visual dashboards with graphs, gauges, and alerts.
- Open http://localhost:3000 in your browser.
- Log in with the default credentials: username admin and password admin.
- When prompted to change the password, click **Skip** for now.
First, connect Grafana to Prometheus as its data source.
- Click the hamburger menu icon in the top left corner.
- Select **Connections**, then **Data sources**.
- Click **Add data source**.
- Select **Prometheus** from the list.
- In the **Prometheus server URL** field, enter http://prometheus:9090.
- Scroll down and click **Save & test**.
Why prometheus:9090 instead of localhost?
Both Grafana and Prometheus run as Docker containers in the same docker-compose network. Within that network, containers reach each other by their service name. Grafana calls Prometheus using the service name prometheus from the docker-compose file.
You should see a green "Successfully queried the Prometheus API" message. Now create the GateGuard dashboard.
- Click the hamburger menu icon again.
- Select **Dashboards**.
- Click **Create dashboard**, then **Add visualization**.
- Select your **Prometheus** data source.
You will create 6 panels. For each panel, enter the PromQL query in the query editor, set the panel title, then click **Apply** to save it to the dashboard.
- Create the first panel with title Request Rate and this query:
rate(http_server_requests_seconds_count[5m])
- Set the visualization type to **Time series** in the right panel.
- Click **Apply** in the top right.
- Click **Add** in the dashboard toolbar, then **Visualization** to add the next panel.
Repeat this process for the remaining 5 panels using these queries and titles:
- Response Time (p50/p95/p99) panel. Add three queries in the same panel using the **+ Query** button:
histogram_quantile(0.50, rate(http_server_requests_seconds_bucket[5m]))
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))
histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m]))
- Error Rate panel with visualization type **Stat**:
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))
- JVM Memory Usage panel with visualization type **Time series**:
jvm_memory_used_bytes{area="heap"}
- Active DB Connections panel with visualization type **Gauge**:
hikaricp_connections_active
- Cache Hit Rate panel with visualization type **Stat**:
sum(rate(cache_gets_total{result="hit"}[5m])) / sum(rate(cache_gets_total[5m]))
- After adding all 6 panels, click the **Save dashboard** icon (floppy disk) in the top toolbar.
- Name the dashboard GateGuard and click **Save**.
What are these PromQL queries measuring?
- rate() calculates the per-second rate of change over a 5-minute window.
- histogram_quantile() gives you percentile response times. p95 means 95% of requests complete faster than this value.
- hikaricp_connections_active comes from HikariCP, the connection pool Spring Boot uses for PostgreSQL.
- cache_gets_total is exposed by Micrometer's Redis cache integration, tracking cache hits vs misses.
Your dashboard should now display 6 panels. Some panels may show "No data" if no requests have been made recently. Send a few test requests via Postman to your backend to see the graphs populate.
Panels all showing No data?
First, confirm the data source is working by going back to **Connections > Data sources > Prometheus** and clicking **Save & test** again.
Make sure your Spring Boot app has received at least one HTTP request since it started. Hit any endpoint (like /actuator/health) and wait 15 seconds for Prometheus to scrape.
Help me troubleshoot my Grafana dashboard showing no data
Add custom business metrics
The built-in metrics cover system health, but they do not tell you about GateGuard-specific activity. Custom business metrics track events that matter to your application: how many visitors checked in, how long OCR processing takes, how much money has been collected.
- Create a new package metrics under your main source package (right-click your base package in IntelliJ, select **New > Package**).
- Create a new Java class called GateGuardMetrics.java inside the metrics package with the following code:
package com.gateguard.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class GateGuardMetrics {
private final Counter visitorsCheckedInTotal;
private final Counter paymentAmountTotal;
private final AtomicInteger pendingApprovals;
private final Timer ocrProcessingTime;
public GateGuardMetrics(MeterRegistry registry) {
// Counter: increments each time a visitor checks in
this.visitorsCheckedInTotal = Counter.builder("visitors_checked_in_total")
.description("Total number of visitors checked in")
.register(registry);
// Counter: tracks cumulative payment amounts
this.paymentAmountTotal = Counter.builder("payment_amount_total")
.description("Total payment amount processed in cents")
.register(registry);
// Gauge: reads current count of pending approvals
this.pendingApprovals = new AtomicInteger(0);
Gauge.builder("pending_approvals", pendingApprovals, AtomicInteger::get)
.description("Number of pending visitor approvals")
.register(registry);
// Timer/Histogram: measures OCR processing duration
this.ocrProcessingTime = Timer.builder("ocr_processing_time_seconds")
.description("Time taken to process OCR requests")
.register(registry);
}
// Call when a visitor successfully checks in
public void recordVisitorCheckIn() {
visitorsCheckedInTotal.increment();
}
// Call when a payment is confirmed via Stripe webhook
public void recordPayment(double amount) {
paymentAmountTotal.increment(amount);
}
// Call to update the pending approvals gauge
public void updatePendingApprovals(int count) {
pendingApprovals.set(count);
}
// Call to record OCR processing duration
public void recordOcrProcessingTime(Duration duration) {
ocrProcessingTime.record(duration);
}
}
What are these metric types?
- Counter only goes up. Perfect for totals like check-ins or payments collected.
- Gauge goes up and down. Perfect for current-state values like pending approvals.
- Timer records durations as a histogram. Prometheus can calculate percentiles from it, just like the response time panel.
Now wire these metrics into your existing controllers and services.
- Open VisitCheckInController.java.
- Inject GateGuardMetrics via the constructor and call recordVisitorCheckIn() after a successful check-in. Add this line where the check-in is confirmed:
// Record the check-in metric after successful visitor entry
gateGuardMetrics.recordVisitorCheckIn();
- Open PaymentController.java.
- Inject GateGuardMetrics and call recordPayment(amount) inside the Stripe webhook handler when payment succeeds:
// Record the payment amount when Stripe confirms success
gateGuardMetrics.recordPayment(amount);
- Open OcrClient.java.
- Inject GateGuardMetrics and wrap the OCR service call with timing code:
// Time the OCR processing and record the duration
long startTime = System.nanoTime();
// ... existing OCR call to FastAPI service ...
long endTime = System.nanoTime();
gateGuardMetrics.recordOcrProcessingTime(Duration.ofNanos(endTime - startTime));
What does this timing pattern do?
By recording the nanosecond duration around the OCR HTTP call, Micrometer generates a histogram that Prometheus can query. You will be able to see p50 and p99 OCR processing times in Grafana, just like you did for HTTP response times.
- Save all modified files and restart your Spring Boot application.
- Verify your custom metrics are exposed by running:
curl http://localhost:8081/actuator/prometheus | grep visitors_checked_in
You should see a line like visitors_checked_in_total 0.0. The value starts at zero and increments as visitors check in.
Now add 3 more panels to your Grafana dashboard for the custom metrics.
- Go back to your GateGuard dashboard in Grafana.
- Click **Add** in the toolbar, then **Visualization**.
- Create a Visitors Checked In panel with visualization type **Stat**:
visitors_checked_in_total
- Create a Payments Collected panel with visualization type **Stat**:
payment_amount_total
- Create an OCR Processing Time panel with visualization type **Time series**:
histogram_quantile(0.95, rate(ocr_processing_time_seconds_bucket[5m]))
- Click the **Save dashboard** icon to save your updated dashboard.
Your GateGuard dashboard now has 9 panels total: 6 system panels and 3 business metric panels.
Generate some test data
To see your custom metrics move, use Postman to submit a visitor check-in or trigger the OCR endpoint. Wait 15 seconds for Prometheus to scrape, then refresh the Grafana dashboard.
Custom metrics not appearing in Grafana?
First check that the metric exists in Prometheus by visiting http://localhost:9090 and typing the metric name (like visitors_checked_in_total) in the query field.
If the metric is not found, make sure you restarted your Spring Boot app after adding the GateGuardMetrics class. Spring needs to register the component at startup.
Help me debug my custom Prometheus metrics
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files: pom.xml, application.yml, GateGuardMetrics.java, prometheus.yml, and docker-compose.monitoring.yml.
ⓧ I'd like to double check the full code
Here are the complete files you created or modified in this step.
# Expose Actuator endpoints for monitoring
management:
endpoints:
web:
exposure:
include: prometheus,health,info,metrics
endpoint:
prometheus:
enabled: true
# Prometheus configuration for scraping GateGuard metrics
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'gateguard-backend'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['host.docker.internal:8081']
# Docker Compose for monitoring stack
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
package com.gateguard.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class GateGuardMetrics {
private final Counter visitorsCheckedInTotal;
private final Counter paymentAmountTotal;
private final AtomicInteger pendingApprovals;
private final Timer ocrProcessingTime;
public GateGuardMetrics(MeterRegistry registry) {
// Counter: increments each time a visitor checks in
this.visitorsCheckedInTotal = Counter.builder("visitors_checked_in_total")
.description("Total number of visitors checked in")
.register(registry);
// Counter: tracks cumulative payment amounts
this.paymentAmountTotal = Counter.builder("payment_amount_total")
.description("Total payment amount processed in cents")
.register(registry);
// Gauge: reads current count of pending approvals
this.pendingApprovals = new AtomicInteger(0);
Gauge.builder("pending_approvals", pendingApprovals, AtomicInteger::get)
.description("Number of pending visitor approvals")
.register(registry);
// Timer/Histogram: measures OCR processing duration
this.ocrProcessingTime = Timer.builder("ocr_processing_time_seconds")
.description("Time taken to process OCR requests")
.register(registry);
}
// Call when a visitor successfully checks in
public void recordVisitorCheckIn() {
visitorsCheckedInTotal.increment();
}
// Call when a payment is confirmed via Stripe webhook
public void recordPayment(double amount) {
paymentAmountTotal.increment(amount);
}
// Call to update the pending approvals gauge
public void updatePendingApprovals(int count) {
pendingApprovals.set(count);
}
// Call to record OCR processing duration
public void recordOcrProcessingTime(Duration duration) {
ocrProcessingTime.record(duration);
}
}
Your monitoring stack is live. You can now observe your application's health, performance, and business activity in real time. Next up, you will containerize all of GateGuard's services with Docker and deploy the entire system to AWS.
Containerize and Deploy to AWS
Your entire GateGuard platform is running locally with monitoring, caching, rate limiting, and all core services communicating perfectly. Now it is time to package everything into portable containers and deploy to the cloud.
In this step, you will create Dockerfiles for each service, orchestrate them with Docker Compose, provision AWS infrastructure, and deploy your containerized application to a live server.
In this step, get ready to:
- Create Dockerfiles for the Spring Boot backend, FastAPI OCR service, and React admin dashboard.
- Build a Docker Compose configuration that runs all services together locally.
- Provision AWS infrastructure (EC2 and RDS) and deploy your containerized application.
Create Dockerfiles for each service
Each service in GateGuard needs its own Dockerfile that describes how to build a production-ready container image. You will create three Dockerfiles using different strategies for each technology stack.
- Create a file called Dockerfile inside your backend folder (the Spring Boot project root) with the following content:
# Stage 1: Build the application with Maven
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn package -DskipTests
# Stage 2: Run with a lightweight JRE image
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
This is a multi-stage build. The first stage uses a full Maven image to compile your Java code and produce a JAR file.
The second stage copies only the built JAR into a slim JRE-only image. This keeps your final container small (around 300MB instead of 1GB+) because build tools like Maven and the JDK compiler are discarded.
- Create a file called Dockerfile inside your ocr-service folder (where your FastAPI main.py lives) with the following content:
FROM python:3.12-slim
WORKDIR /app
# Install dependencies first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
EXPOSE 8001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
Why copy requirements.txt separately?
Docker caches each layer. By copying requirements.txt and running pip install before copying your source code, Docker only re-installs dependencies when requirements.txt changes. Code-only changes rebuild in seconds instead of minutes.
- Create a file called Dockerfile inside your gateguard-admin folder (the React project root) with the following content:
# Stage 1: Build the React app
FROM node:20 AS build
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
What does this Dockerfile do?
The first stage installs Node dependencies and builds your React app into static HTML/JS/CSS files in the dist folder.
The second stage copies those static files into an nginx:alpine image. Nginx serves them efficiently at about 25MB total. No Node runtime needed in production.
- Save all three Dockerfiles.
- Verify the files exist by running:
ls backend/Dockerfile ocr-service/Dockerfile gateguard-admin/Dockerfile
You should see all three file paths listed without errors.
File not found?
Make sure you created each Dockerfile (capital D, no extension) in the correct project subfolder. The file name is exactly Dockerfile with no .txt or other suffix.
Help me find where to put my Dockerfiles.
Create docker-compose.yml for local orchestration
Running each service individually with separate docker build and docker run commands is tedious. Docker Compose lets you define and run all services together with a single command.
- Create a docker-compose.yml file in your project root (the parent folder that contains backend, ocr-service, and gateguard-admin) with the following content:
version: '3.8'
services:
# PostgreSQL database
postgres:
image: postgres:16
ports:
- "5432:5432"
environment:
POSTGRES_DB: gateguard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- gateguard-net
# Redis cache
redis:
image: redis:7
ports:
- "6379:6379"
networks:
- gateguard-net
# RabbitMQ message broker
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
networks:
- gateguard-net
# Spring Boot backend
backend:
build: ./backend
ports:
- "8081:8081"
environment:
DB_URL: jdbc:postgresql://postgres:5432/gateguard
DB_USERNAME: postgres
DB_PASSWORD: postgres
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- postgres
- redis
- rabbitmq
networks:
- gateguard-net
# Spring Cloud Gateway
gateway:
build: ./gateway
ports:
- "8080:8080"
environment:
BACKEND_URL: http://backend:8081
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- backend
networks:
- gateguard-net
# FastAPI OCR service
ocr-service:
build: ./ocr-service
ports:
- "8001:8001"
networks:
- gateguard-net
# React admin dashboard
admin:
build: ./gateguard-admin
ports:
- "80:80"
depends_on:
- gateway
networks:
- gateguard-net
volumes:
pgdata:
networks:
gateguard-net:
driver: bridge
What does this configuration do?
- Each service gets a name (like backend, postgres) that acts as its hostname on the internal network. Services find each other by name instead of localhost.
- The depends_on field ensures services start in the right order.
- The gateguard-net bridge network connects all containers so they can communicate internally.
- The pgdata volume persists database data across container restarts.
- Save docker-compose.yml.
- Build and start all services by running:
docker compose up --build -d
This builds all images from the Dockerfiles and starts the containers in detached mode. The first build takes several minutes as it downloads base images and compiles code.
- Verify all containers are running:
docker compose ps
You should see all seven services listed with a status of Up.
- Test the admin dashboard is accessible by opening http://localhost in your browser. You should see the GateGuard admin login page.
- Test the API gateway by opening http://localhost:8080 in your browser. You should see the gateway responding.
Containers exiting immediately?
Check the logs for the failing container by running docker compose logs backend (replace backend with the failing service name). Common causes include the backend trying to connect to PostgreSQL before it is ready.
If the backend fails with a database connection error, wait 10 seconds and run docker compose up -d backend to restart just that container.
Help me debug my Docker Compose startup.
Set up AWS infrastructure and deploy
Your containers work locally. Now you will provision cloud infrastructure on AWS and deploy the same containerized services to a live server accessible over the internet.
You will create an EC2 instance to run your containers and an RDS instance for a managed production database.
- Open the AWS Management Console in your browser at https://console.aws.amazon.com.
- Navigate to the EC2 service by typing EC2 in the search bar and selecting it.
- Click Launch instances.
- Enter gateguard-server in the Name field.
- Under Application and OS Images, select Amazon Linux 2023 (it should be the default selection).
- Under Instance type, select t3.small from the dropdown.
- Under Key pair, click Create new key pair.
- Enter gateguard-key as the name.
- Select .pem format and click Create key pair. The file downloads automatically. Keep it safe.
- Under Network settings, click Edit.
- Keep Allow SSH traffic checked.
- Click Add security group rule three times to add rules for ports 80, 8080, and 8081. For each rule, set Type to Custom TCP, enter the port number, and set Source to Anywhere.
- Click Launch instance.
Why t3.small?
A t3.small instance provides 2 vCPUs and 2GB of RAM. This is enough to run the Spring Boot backend, FastAPI service, nginx, and supporting containers simultaneously. The t3.micro (1GB RAM) would run out of memory trying to build and run multiple Java/Python containers.
- Wait for the instance state to show Running in the EC2 dashboard.
- Copy the Public IPv4 DNS value from the instance details. You will need this to SSH in.
Now create the RDS database instance.
- Navigate to the RDS service by typing RDS in the AWS search bar and selecting it.
- Click Create database.
- Select Standard create.
- Under Engine options, select PostgreSQL.
- For Engine version, select PostgreSQL 15.
- Under Templates, select Free tier (or Dev/Test if Free tier is not visible).
- Under Settings, enter gateguard-db as the DB instance identifier.
- Set the Master username to postgres.
- Set and confirm a Master password (remember this for later).
- Under Instance configuration, select db.t3.micro.
- Under Connectivity, select Connect to an EC2 compute resource and choose your gateguard-server instance. This automatically configures security groups so only your EC2 can reach the database.
- Under Additional configuration, enter gateguard as the Initial database name.
- Click Create database.
How long does RDS creation take?
RDS instances typically take 5 to 10 minutes to become available. You can proceed with the EC2 setup while waiting. The Status column in the RDS dashboard changes from Creating to Available when it is ready.
While RDS is creating, SSH into your EC2 instance and install Docker.
- Open your terminal and connect to the EC2 instance by running:
ssh -i [[PEMPATH="path/to/gateguard-key.pem"]] ec2-user@[[EC2_DNS="your EC2 Public IPv4 DNS"]]
Permission denied on .pem file
If you get a permissions error, first restrict the key file permissions by running chmod 400 on your .pem file, then retry the SSH command.
- Install Docker and Docker Compose on the EC2 instance by running:
# Update system packages
sudo dnf update -y
# Install Docker
sudo dnf install -y docker
# Start and enable Docker
sudo systemctl start docker
sudo systemctl enable docker
# Add ec2-user to docker group
sudo usermod -aG docker ec2-user
# Install Docker Compose plugin
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
What do these commands do?
- Amazon Linux 2023 includes Docker in its standard repositories, so dnf install docker installs it directly.
- The usermod command lets ec2-user run Docker commands without sudo.
- Docker Compose is installed as a CLI plugin so you can use docker compose commands.
- Apply the group change by disconnecting and reconnecting:
exit
- SSH back into the instance using the same command as before.
- Verify Docker is working by running:
docker --version
docker compose version
You should see the Docker version number and Docker Compose version printed without errors.
Now create a production Docker Compose file that points to your RDS database instead of a local PostgreSQL container.
- Go back to the RDS dashboard in your browser and copy the Endpoint value for your gateguard-db instance. It looks like gateguard-db.xxxx.us-east-1.rds.amazonaws.com.
- On your local machine, create a docker-compose.prod.yml file in your project root with the following content:
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8081:8081"
environment:
DB_URL: jdbc:postgresql://[[RDS_ENDPOINT="your-rds-endpoint.region.rds.amazonaws.com"]]:5432/gateguard
DB_USERNAME: postgres
DB_PASSWORD: [[RDS_PASSWORD="your RDS master password"]]
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
OCR_SERVICE_URL: http://ocr-service:8001
S3_BUCKET: gateguard-images
AWS_REGION: [[AWS_REGION="us-east-1"]]
depends_on:
- redis
- rabbitmq
networks:
- gateguard-net
gateway:
build: ./gateway
ports:
- "8080:8080"
environment:
BACKEND_URL: http://backend:8081
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- backend
networks:
- gateguard-net
ocr-service:
build: ./ocr-service
ports:
- "8001:8001"
networks:
- gateguard-net
admin:
build: ./gateguard-admin
ports:
- "80:80"
depends_on:
- gateway
networks:
- gateguard-net
redis:
image: redis:7
ports:
- "6379:6379"
networks:
- gateguard-net
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
networks:
- gateguard-net
networks:
gateguard-net:
driver: bridge
What changed from the local version?
The production compose file removes the postgres service entirely. Instead, the backend's DB_URL points to your RDS endpoint. It also adds S3 bucket and AWS region configuration for image storage.
Now transfer your project to the EC2 instance and deploy.
- From your local machine, copy your project files to EC2 by running:
scp -i [[PEMPATH="path/to/gateguard-key.pem"]] -r ./backend ./ocr-service ./gateguard-admin ./gateway ./docker-compose.prod.yml ec2-user@[[EC2_DNS="your EC2 Public IPv4 DNS"]]:~/gateguard/
- SSH back into your EC2 instance.
- Navigate to the project directory and start the services by running:
cd ~/gateguard
docker compose -f docker-compose.prod.yml up --build -d
The build takes several minutes on the first run as it downloads images and compiles the Java backend.
- Verify the deployment by running:
docker compose -f docker-compose.prod.yml ps
All services should show Up status.
- Test your deployed application by opening your EC2 public IP in a browser. Navigate to http://your EC2 public IP to see the admin dashboard.
- Test the API by navigating to http://your EC2 public IP:8080 to verify the gateway is responding.
Can't access the app in the browser?
Check that your EC2 security group allows inbound traffic on ports 80 and 8080. Go to EC2, select your instance, click the Security tab, and verify the inbound rules include ports 80, 8080, and 8081.
If the security group looks correct but the page still times out, check that the containers are running with docker compose -f docker-compose.prod.yml logs.
Help me troubleshoot my AWS deployment.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and your EC2 deployment is accessible.
ⓧ I'd like to double check the full code
Here are the three Dockerfiles and both compose files for reference:
# Stage 1: Build the application with Maven
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn package -DskipTests
# Stage 2: Run with a lightweight JRE image
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
FROM python:3.12-slim
WORKDIR /app
# Install dependencies first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
EXPOSE 8001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
# Stage 1: Build the React app
FROM node:20 AS build
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
version: '3.8'
services:
# PostgreSQL database
postgres:
image: postgres:16
ports:
- "5432:5432"
environment:
POSTGRES_DB: gateguard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- gateguard-net
# Redis cache
redis:
image: redis:7
ports:
- "6379:6379"
networks:
- gateguard-net
# RabbitMQ message broker
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
networks:
- gateguard-net
# Spring Boot backend
backend:
build: ./backend
ports:
- "8081:8081"
environment:
DB_URL: jdbc:postgresql://postgres:5432/gateguard
DB_USERNAME: postgres
DB_PASSWORD: postgres
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- postgres
- redis
- rabbitmq
networks:
- gateguard-net
# Spring Cloud Gateway
gateway:
build: ./gateway
ports:
- "8080:8080"
environment:
BACKEND_URL: http://backend:8081
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- backend
networks:
- gateguard-net
# FastAPI OCR service
ocr-service:
build: ./ocr-service
ports:
- "8001:8001"
networks:
- gateguard-net
# React admin dashboard
admin:
build: ./gateguard-admin
ports:
- "80:80"
depends_on:
- gateway
networks:
- gateguard-net
volumes:
pgdata:
networks:
gateguard-net:
driver: bridge
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8081:8081"
environment:
DB_URL: jdbc:postgresql://YOUR-RDS-ENDPOINT:5432/gateguard
DB_USERNAME: postgres
DB_PASSWORD: YOUR-RDS-PASSWORD
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
OCR_SERVICE_URL: http://ocr-service:8001
S3_BUCKET: gateguard-images
AWS_REGION: us-east-1
depends_on:
- redis
- rabbitmq
networks:
- gateguard-net
gateway:
build: ./gateway
ports:
- "8080:8080"
environment:
BACKEND_URL: http://backend:8081
OCR_SERVICE_URL: http://ocr-service:8001
depends_on:
- backend
networks:
- gateguard-net
ocr-service:
build: ./ocr-service
ports:
- "8001:8001"
networks:
- gateguard-net
admin:
build: ./gateguard-admin
ports:
- "80:80"
depends_on:
- gateway
networks:
- gateguard-net
redis:
image: redis:7
ports:
- "6379:6379"
networks:
- gateguard-net
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
networks:
- gateguard-net
networks:
gateguard-net:
driver: bridge
Your GateGuard platform is now live on AWS. The admin dashboard is served via nginx on port 80, the API gateway routes traffic on port 8080, and your backend connects to a managed RDS PostgreSQL database. Next up, you will extend this system with real-time WebSocket notifications in the Secret Mission.
Secret mission
Add Real-Time WebSocket Notifications
Your notification system currently uses polling. Every 30 seconds, the mobile app checks the backend for new visitor alerts. That works, but it means homeowners could wait up to 30 seconds before learning someone is at their gate. In this secret mission, you will replace polling with WebSocket connections that push notifications instantly to all connected clients.
Clean Up Your Resources
Clean Up Your Resources
This project uses AWS resources that cost money after your $200 free credits are exhausted. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
Your EC2 instance (t3.small) and RDS database (db.t3.micro) consume credits continuously while running. If your $200 free credits expire, expect approximately $40-60/month in charges.
If you are not actively working on this project, choose Pause or Delete below to avoid unexpected bills.
Resources you used:
- AWS EC2 instance (t3.small) running your Docker containers.
- AWS RDS PostgreSQL instance (db.t3.micro) hosting your database.
- AWS S3 bucket storing visitor photos and ID images.
- AWS ECR images for your Docker containers.
- Local Docker containers (PostgreSQL, Redis, RabbitMQ, Prometheus, Grafana).
- Local project files (Spring Boot backend, FastAPI OCR service, React admin dashboard, React Native mobile app).
- Ollama with the Llama 3.2 model (~5GB on disk).
- Stripe account (test mode, no ongoing cost).
Keep everything running
No action needed. Choose this if you are still actively building or testing GateGuard.
- Your EC2 instance, RDS database, and S3 bucket will continue consuming your $200 free credits.
- Monitor your credit usage in AWS Cost Explorer to avoid surprises.
- Your local Docker containers continue running until you stop Docker Desktop or restart your Mac.
Pause - I'll come back to this later
Stop running cloud instances to preserve your free credits. Your data and configuration are saved.
- In the AWS EC2 console, select your instance, then choose Instance state > Stop instance.
- In the AWS RDS console, select your database, then choose Actions > Stop temporarily.
- Leave your S3 bucket as-is. Storage costs for images are negligible.
- On your Mac, quit Docker Desktop to free up RAM. Your containers will restart when you reopen Docker Desktop.
- Stopped EC2 and RDS instances do not incur compute charges. EBS volumes still cost approximately $0.10/GB/month.
Delete - I don't want to use this again
Remove all cloud resources and local project files. This brings your AWS costs to $0. Your local development environment still works with Docker if you keep those files.
- In the AWS EC2 console, select your instance, then choose Instance state > Terminate instance.
- In the AWS RDS console, select your database, then choose Actions > Delete. Skip the final snapshot when prompted.
- In the AWS S3 console, open your bucket, select all objects and delete them. Then delete the empty bucket.
- In the AWS ECR console, delete any repositories you created for GateGuard Docker images.
- On your Mac, stop and remove all local Docker containers by running:
docker compose down -v
- Delete the project folders from your Desktop:
cd ~/Desktop
rm -rf gateguard-backend gateguard-ocr gateguard-admin GateGuardMobile gateguard-gateway
- Remove the Ollama model to free up ~5GB of disk space:
ollama rm llama3.2
- Your Stripe test account has no cost and can be left as-is or deleted from the Stripe dashboard under Settings > Account.
That's a Wrap!
That's a Wrap!
Nice work! You just built GateGuard, a complete subdivision visitor management platform spanning four programming languages, multiple microservices, and a full cloud deployment.
You've learned how to:
- Built a full-stack microservices platform with a Spring Boot backend, Python FastAPI OCR service using PaddleOCR, a React Native mobile app, and a React admin dashboard with AI-powered insights from Ollama.
- Implemented production system design patterns including JWT authentication, Redis caching, RabbitMQ message queues, rate limiting, and Prometheus monitoring with Grafana dashboards.
- Containerized all services with Docker and deployed the entire application to AWS using EC2, RDS PostgreSQL, and S3 with Stripe payment processing for HOA dues collection.
- Secret Mission: Added real-time WebSocket notifications for instant visitor alerts across all clients, with a live activity feed on the admin dashboard.
Ready to quiz yourself?