feat: initialize frontend with React, Vite, and Tailwind CSS

- Added package.json for project dependencies and scripts.
- Configured PostCSS with Tailwind CSS.
- Created main application structure with App component and routing.
- Implemented API client for handling requests with Axios.
- Developed authentication API for login, logout, and user verification.
- Created entities API for managing virtual entities.
- Implemented logs API for fetching dispatch logs.
- Added navigation bar component for app navigation.
- Created protected route component for route guarding.
- Set up global CSS with Tailwind directives.
- Configured main entry point for React application.
- Developed basic Dashboard and Login pages.
- Set up router for application navigation.
- Added Jest testing setup for testing library.
- Configured Tailwind CSS with content paths.
- Set TypeScript configuration for frontend.
- Created Vite configuration for development and production builds.
- Added Nginx configuration for serving the application and proxying API requests.
This commit is contained in:
2026-03-26 15:04:12 -03:00
parent fa6731de98
commit ca2e645f02
47 changed files with 7215 additions and 5 deletions

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# Copy this file to .env and fill in your values.
# Never commit the actual .env file.
# ── Authentication ────────────────────────────────────────────────────────────
APP_PASSWORD=changeme
JWT_SECRET=change-this-to-a-long-random-secret-at-least-256-bits
JWT_EXPIRATION_MS=86400000
# ── Database ──────────────────────────────────────────────────────────────────
SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/condado
SPRING_DATASOURCE_USERNAME=condado
SPRING_DATASOURCE_PASSWORD=condado
# ── SMTP (email sending) ──────────────────────────────────────────────────────
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=company@condado.example
MAIL_PASSWORD=
# ── IMAP (email reading) ──────────────────────────────────────────────────────
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_INBOX_FOLDER=INBOX
# ── OpenAI ────────────────────────────────────────────────────────────────────
OPENAI_API_KEY=sk-replace-me
OPENAI_MODEL=gpt-4o
# ── Application ───────────────────────────────────────────────────────────────
APP_RECIPIENTS=friend1@example.com,friend2@example.com
# ── Frontend (Vite build-time) ────────────────────────────────────────────────
VITE_API_BASE_URL=http://localhost:80

56
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
backend-test:
name: Backend Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: temurin
cache: gradle
- name: Run tests
run: ./gradlew test --no-daemon
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-test-results
path: backend/build/reports/tests/
frontend-test:
name: Frontend Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test

34
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Publish to Docker Hub
on:
push:
branches:
- main
jobs:
build-and-push:
name: Build & Push All-in-one Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.allinone
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:latest
${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# ── Environment ──────────────────────────────────────────────────────────────
.env
*.env.local
# ── Backend (Gradle / JVM) ───────────────────────────────────────────────────
backend/.gradle/
backend/build/
backend/out/
backend/*.class
backend/.kotlin/
# ── Frontend (Node / Vite) ───────────────────────────────────────────────────
frontend/node_modules/
frontend/dist/
frontend/.vite/
# ── Docker ────────────────────────────────────────────────────────────────────
docker-compose.override.yml
# ── IDEs ─────────────────────────────────────────────────────────────────────
.idea/
*.iml
*.iws
.vscode/
*.suo
*.user
# ── OS ───────────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
# ── Logs ─────────────────────────────────────────────────────────────────────
*.log
logs/

View File

@@ -478,3 +478,15 @@ BODY:
**Image tags pushed on every `main` merge:**
- `<dockerhub-user>/condado-newsletter:latest`
- `<dockerhub-user>/condado-newsletter:<git-sha>` (for pinning)
---
## Step 1 Decisions & Versions
| Decision | Detail |
|---|---|
| Gradle wrapper | **8.14.1** (upgraded from 8.7 — Gradle < 8.14 cannot parse Java version `26`) |
| Spring Boot | **3.4.5** (latest stable at time of scaffold) |
| Kotlin | **2.1.21** (latest stable, bundled with Gradle 8.14.1) |
| Java toolchain | **21** configured in `build.gradle.kts` via `kotlin { jvmToolchain(21) }` — bytecode targets Java 21 regardless of host JDK |
| Frontend test script | `vitest run --passWithNoTests` — prevents CI failure before Step 12 adds real tests |

58
Dockerfile.allinone Normal file
View File

@@ -0,0 +1,58 @@
# ── Stage 1: Build frontend ───────────────────────────────────────────────────
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ── Stage 2: Build backend ────────────────────────────────────────────────────
FROM gradle:8-jdk21-alpine AS backend-build
WORKDIR /app/backend
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
COPY backend/gradle ./gradle
RUN gradle dependencies --no-daemon --quiet || true
COPY backend/src ./src
RUN gradle bootJar --no-daemon -x test
# ── Stage 3: Final all-in-one image ───────────────────────────────────────────
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
nginx \
postgresql \
supervisor \
openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
# PostgreSQL data directory
RUN mkdir -p /var/lib/postgresql/data && chown -R postgres:postgres /var/lib/postgresql
# Copy frontend static files
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
# Copy backend JAR
RUN mkdir -p /app
COPY --from=backend-build /app/backend/build/libs/*.jar /app/app.jar
# Copy Nginx config (internal — backend is on localhost:8080)
COPY nginx/nginx.allinone.conf /etc/nginx/nginx.conf
# Copy Supervisor config
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Copy entrypoint
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -37,7 +37,7 @@ employee is an AI-powered entity that:
| Step | Description | Status |
|------|-----------------------------------------|-------------|
| 0 | Define project & write CLAUDE.md | ✅ Done |
| 1 | Scaffold monorepo structure | ⬜ Pending |
| 1 | Scaffold monorepo structure | ✅ Done |
| 2 | Domain model (JPA entities) | ⬜ Pending |
| 3 | Repositories | ⬜ Pending |
| 4 | Email Reader Service (IMAP) | ⬜ Pending |
@@ -173,10 +173,10 @@ condado-news-letter/
> `.github/workflows/publish.yml`. Do not implement business logic yet — just the skeleton."
**Done when:**
- [ ] `cd backend && ./gradlew build` compiles with no errors.
- [ ] `cd frontend && npm install && npm run build` succeeds.
- [ ] Application starts with `./gradlew bootRun` (backend) without errors.
- [ ] `npm run dev` starts the Vite dev server.
- [x] `cd backend && ./gradlew build` compiles with no errors.
- [x] `cd frontend && npm install && npm run build` succeeds.
- [x] Application starts with `./gradlew bootRun` (backend) without errors.
- [x] `npm run dev` starts the Vite dev server.
- [ ] `docker compose up --build` starts all containers.
---

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# ── Stage 1: Build ───────────────────────────────────────────────────────────
FROM gradle:8-jdk21-alpine AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle gradle
COPY gradlew ./
# Download dependencies first (layer cache)
RUN ./gradlew dependencies --no-daemon || true
COPY src src
RUN ./gradlew bootJar --no-daemon
# ── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

64
backend/build.gradle.kts Normal file
View File

@@ -0,0 +1,64 @@
plugins {
id("org.springframework.boot") version "3.4.5"
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "2.1.21"
kotlin("plugin.spring") version "2.1.21"
kotlin("plugin.jpa") version "2.1.21"
}
group = "com.condado"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
kotlin {
jvmToolchain(21)
}
repositories {
mavenCentral()
}
dependencies {
// ── Spring Boot starters ─────────────────────────────────────────────────
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-security")
// ── Kotlin ───────────────────────────────────────────────────────────────
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// ── JWT (JJWT 0.12.x) ────────────────────────────────────────────────────
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
// ── Database ─────────────────────────────────────────────────────────────
runtimeOnly("org.postgresql:postgresql")
// ── IMAP (Jakarta Mail via Angus Mail) ────────────────────────────────────
implementation("org.eclipse.angus:angus-mail:2.0.3")
// ── OpenAPI / Swagger UI ──────────────────────────────────────────────────
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0")
// ── Test ─────────────────────────────────────────────────────────────────
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.mockito")
}
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.mockk:mockk:1.13.11")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testRuntimeOnly("com.h2database:h2")
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED
kotlin.code.style=official

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
backend/gradlew vendored Normal file
View File

@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1 @@
rootProject.name = "condado-newsletter"

View File

@@ -0,0 +1,13 @@
package com.condado.newsletter
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
class CondadoApplication
fun main(args: Array<String>) {
runApplication<CondadoApplication>(*args)
}

View File

@@ -0,0 +1,42 @@
spring:
datasource:
url: jdbc:h2:mem:condado;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
mail:
host: localhost
port: 1025
username: test
password: test
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app:
password: devpassword
recipients: dev@example.com
jwt:
secret: dev-secret-key-at-least-256-bits-long-for-hs256-algorithm
expiration-ms: 86400000
imap:
host: localhost
port: 993
inbox-folder: INBOX
openai:
api-key: dev-key
model: gpt-4o

View File

@@ -0,0 +1,55 @@
spring:
application:
name: condado-newsletter
datasource:
url: ${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
mail:
host: ${MAIL_HOST}
port: ${MAIL_PORT}
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
server:
port: 8080
app:
password: ${APP_PASSWORD}
recipients: ${APP_RECIPIENTS}
jwt:
secret: ${JWT_SECRET}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
imap:
host: ${IMAP_HOST}
port: ${IMAP_PORT:993}
inbox-folder: ${IMAP_INBOX_FOLDER:INBOX}
openai:
api-key: ${OPENAI_API_KEY}
model: ${OPENAI_MODEL:gpt-4o}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,15 @@
package com.condado.newsletter
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@SpringBootTest
@ActiveProfiles("dev")
class CondadoApplicationTests {
@Test
fun contextLoads() {
// Verifies that the Spring application context starts up without errors.
}
}

71
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,71 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: condado
POSTGRES_USER: ${SPRING_DATASOURCE_USERNAME}
POSTGRES_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- condado-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${SPRING_DATASOURCE_USERNAME} -d condado"]
interval: 10s
timeout: 5s
retries: 5
# ── Backend (Spring Boot) ────────────────────────────────────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
APP_PASSWORD: ${APP_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
IMAP_HOST: ${IMAP_HOST}
IMAP_PORT: ${IMAP_PORT}
IMAP_INBOX_FOLDER: ${IMAP_INBOX_FOLDER}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL}
APP_RECIPIENTS: ${APP_RECIPIENTS}
networks:
- condado-net
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
restart: always
ports:
- "80:80"
depends_on:
- backend
networks:
- condado-net
volumes:
postgres-data:
networks:
condado-net:
driver: bridge

80
docker-compose.yml Normal file
View File

@@ -0,0 +1,80 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: condado
POSTGRES_USER: ${SPRING_DATASOURCE_USERNAME}
POSTGRES_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- condado-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${SPRING_DATASOURCE_USERNAME} -d condado"]
interval: 10s
timeout: 5s
retries: 5
# ── Backend (Spring Boot) ────────────────────────────────────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
APP_PASSWORD: ${APP_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
IMAP_HOST: ${IMAP_HOST}
IMAP_PORT: ${IMAP_PORT}
IMAP_INBOX_FOLDER: ${IMAP_INBOX_FOLDER}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL}
APP_RECIPIENTS: ${APP_RECIPIENTS}
networks:
- condado-net
# ── Frontend + Nginx ─────────────────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
restart: unless-stopped
ports:
- "80:80"
depends_on:
- backend
networks:
- condado-net
# ── Mailhog (DEV ONLY — SMTP trap) ───────────────────────────────────────────
mailhog:
image: mailhog/mailhog:latest
restart: unless-stopped
ports:
- "8025:8025"
networks:
- condado-net
volumes:
postgres-data:
networks:
condado-net:
driver: bridge

23
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# ── Initialise PostgreSQL data directory on first run ─────────────────────────
if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then
echo "Initialising PostgreSQL data directory..."
su -c "/usr/lib/postgresql/16/bin/initdb -D /var/lib/postgresql/data --encoding=UTF8 --locale=C" postgres
# Start postgres temporarily to create the app database and user
su -c "/usr/lib/postgresql/16/bin/pg_ctl -D /var/lib/postgresql/data -w start" postgres
su -c "psql -c \"CREATE USER condado WITH PASSWORD 'condado';\"" postgres
su -c "psql -c \"CREATE DATABASE condado OWNER condado;\"" postgres
su -c "/usr/lib/postgresql/16/bin/pg_ctl -D /var/lib/postgresql/data -w stop" postgres
echo "PostgreSQL initialised."
fi
# ── Ensure supervisor log directory exists ────────────────────────────────────
mkdir -p /var/log/supervisor
# ── Start all services via supervisord ───────────────────────────────────────
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

28
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:postgres]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
user=postgres
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/postgres.log
stderr_logfile=/var/log/supervisor/postgres.err.log
[program:backend]
command=java -jar /app/app.jar
environment=SPRING_DATASOURCE_URL="jdbc:postgresql://localhost:5432/condado",SPRING_DATASOURCE_USERNAME="condado",SPRING_DATASOURCE_PASSWORD="condado"
autostart=true
autorestart=true
startsecs=15
stdout_logfile=/var/log/supervisor/backend.log
stderr_logfile=/var/log/supervisor/backend.err.log
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/nginx.log
stderr_logfile=/var/log/supervisor/nginx.err.log

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# ── Stage 1: Build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: Serve with Nginx ─────────────────────────────────────────────────
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.docker.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Condado Abaixo da Média SA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5723
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "condado-newsletter-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
"lucide-react": "^0.390.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.4.5",
"vite": "^5.2.13",
"tailwindcss": "^3.4.4",
"postcss": "^8.4.38",
"autoprefixer": "^10.4.19",
"vitest": "^1.6.0",
"@vitest/ui": "^1.6.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/user-event": "^14.5.2",
"jsdom": "^24.1.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
export default function App() {
return <RouterProvider router={router} />
}

View File

@@ -0,0 +1,11 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: '/api',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
export default apiClient

View File

@@ -0,0 +1,21 @@
import apiClient from './apiClient'
export interface LoginRequest {
password: string
}
/** POST /api/auth/login — validates password, sets httpOnly JWT cookie on success. */
export async function login(data: LoginRequest): Promise<void> {
await apiClient.post('/auth/login', data)
}
/** POST /api/auth/logout — clears the JWT cookie. */
export async function logout(): Promise<void> {
await apiClient.post('/auth/logout')
}
/** GET /api/auth/me — verifies the current JWT cookie is valid. */
export async function getMe(): Promise<{ message: string }> {
const response = await apiClient.get<{ message: string }>('/auth/me')
return response.data
}

View File

@@ -0,0 +1,58 @@
import apiClient from './apiClient'
export interface VirtualEntityResponse {
id: string
name: string
email: string
jobTitle: string
personality: string
scheduleCron: string
contextWindowDays: number
active: boolean
createdAt: string
}
export interface VirtualEntityCreateDto {
name: string
email: string
jobTitle: string
personality: string
scheduleCron: string
contextWindowDays: number
}
export type VirtualEntityUpdateDto = Partial<VirtualEntityCreateDto>
/** GET /api/v1/virtual-entities — list all virtual entities. */
export async function getEntities(): Promise<VirtualEntityResponse[]> {
const response = await apiClient.get<VirtualEntityResponse[]>('/v1/virtual-entities')
return response.data
}
/** GET /api/v1/virtual-entities/:id — get one entity by id. */
export async function getEntity(id: string): Promise<VirtualEntityResponse> {
const response = await apiClient.get<VirtualEntityResponse>(`/v1/virtual-entities/${id}`)
return response.data
}
/** POST /api/v1/virtual-entities — create a new entity. */
export async function createEntity(data: VirtualEntityCreateDto): Promise<VirtualEntityResponse> {
const response = await apiClient.post<VirtualEntityResponse>('/v1/virtual-entities', data)
return response.data
}
/** PUT /api/v1/virtual-entities/:id — update an entity. */
export async function updateEntity(id: string, data: VirtualEntityUpdateDto): Promise<VirtualEntityResponse> {
const response = await apiClient.put<VirtualEntityResponse>(`/v1/virtual-entities/${id}`, data)
return response.data
}
/** DELETE /api/v1/virtual-entities/:id — soft-delete (deactivate) an entity. */
export async function deleteEntity(id: string): Promise<void> {
await apiClient.delete(`/v1/virtual-entities/${id}`)
}
/** POST /api/v1/virtual-entities/:id/trigger — manually trigger the entity pipeline. */
export async function triggerEntity(id: string): Promise<void> {
await apiClient.post(`/v1/virtual-entities/${id}/trigger`)
}

View File

@@ -0,0 +1,27 @@
import apiClient from './apiClient'
export type DispatchStatus = 'PENDING' | 'SENT' | 'FAILED'
export interface DispatchLogResponse {
id: string
entityId: string
promptSent: string
aiResponse: string
emailSubject: string
emailBody: string
status: DispatchStatus
errorMessage: string | null
dispatchedAt: string
}
/** GET /api/v1/dispatch-logs — list all dispatch logs. */
export async function getLogs(): Promise<DispatchLogResponse[]> {
const response = await apiClient.get<DispatchLogResponse[]>('/v1/dispatch-logs')
return response.data
}
/** GET /api/v1/dispatch-logs/entity/:id — list logs for a specific entity. */
export async function getLogsByEntity(entityId: string): Promise<DispatchLogResponse[]> {
const response = await apiClient.get<DispatchLogResponse[]>(`/v1/dispatch-logs/entity/${entityId}`)
return response.data
}

View File

@@ -0,0 +1,29 @@
import { Link, useLocation } from 'react-router-dom'
const NAV_LINKS = [
{ to: '/', label: 'Dashboard' },
{ to: '/entities', label: 'Entities' },
{ to: '/logs', label: 'Logs' },
]
/** Top navigation bar for authenticated pages. */
export default function NavBar() {
const { pathname } = useLocation()
return (
<nav className="border-b bg-white">
<div className="mx-auto flex max-w-7xl items-center gap-6 px-4 py-3">
<span className="font-semibold text-gray-900">Condado SA</span>
{NAV_LINKS.map(({ to, label }) => (
<Link
key={to}
to={to}
className={`text-sm ${pathname === to ? 'font-semibold text-blue-600' : 'text-gray-600 hover:text-gray-900'}`}
>
{label}
</Link>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,22 @@
import { type ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getMe } from '../api/authApi'
interface ProtectedRouteProps {
children: ReactNode
}
/** Redirects to /login if the current JWT session is not valid. */
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ['auth', 'me'],
queryFn: getMe,
retry: false,
})
if (isLoading) return <div>Loading...</div>
if (isError || !data) return <Navigate to="/login" replace />
return <>{children}</>
}

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

22
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div className="p-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-sm text-gray-500">Dashboard coming in Step 11.</p>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Condado Abaixo da Média SA
</h1>
<p className="text-sm text-gray-500">Login page coming in Step 11.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { createBrowserRouter } from 'react-router-dom'
import { lazy, Suspense } from 'react'
const LoginPage = lazy(() => import('../pages/LoginPage'))
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
export const router = createBrowserRouter([
{
path: '/login',
element: (
<Suspense fallback={<div>Loading...</div>}>
<LoginPage />
</Suspense>
),
},
{
path: '/',
element: (
<Suspense fallback={<div>Loading...</div>}>
<DashboardPage />
</Suspense>
),
},
])

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

27
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})

51
nginx/nginx.allinone.conf Normal file
View File

@@ -0,0 +1,51 @@
# Nginx config for the all-in-one image.
# Backend (Spring Boot) runs on localhost:8080 inside the same container.
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location /swagger-ui/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /v3/api-docs {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}

51
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,51 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — unknown paths serve index.html so React Router works
location / {
try_files $uri $uri/ /index.html;
}
# Proxy all /api/* requests to the Spring Boot backend
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Convenience: proxy Swagger UI and OpenAPI spec
location /swagger-ui/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /v3/api-docs {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}