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:
33
.env.example
Normal file
33
.env.example
Normal 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
56
.github/workflows/ci.yml
vendored
Normal 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
34
.github/workflows/publish.yml
vendored
Normal 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
34
.gitignore
vendored
Normal 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/
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -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
58
Dockerfile.allinone
Normal 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"]
|
||||
@@ -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
17
backend/Dockerfile
Normal 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
64
backend/build.gradle.kts
Normal 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()
|
||||
}
|
||||
2
backend/gradle.properties
Normal file
2
backend/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError --enable-native-access=ALL-UNNAMED
|
||||
kotlin.code.style=official
|
||||
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
249
backend/gradlew
vendored
Normal 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
92
backend/gradlew.bat
vendored
Normal 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
|
||||
1
backend/settings.gradle.kts
Normal file
1
backend/settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = "condado-newsletter"
|
||||
@@ -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)
|
||||
}
|
||||
42
backend/src/main/resources/application-dev.yml
Normal file
42
backend/src/main/resources/application-dev.yml
Normal 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
|
||||
55
backend/src/main/resources/application.yml
Normal file
55
backend/src/main/resources/application.yml
Normal 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
|
||||
@@ -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
71
docker-compose.prod.yml
Normal 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
80
docker-compose.yml
Normal 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
23
docker/entrypoint.sh
Normal 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
28
docker/supervisord.conf
Normal 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
14
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
5723
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/package.json
Normal file
48
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
frontend/src/App.tsx
Normal file
6
frontend/src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { router } from './router'
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
11
frontend/src/api/apiClient.ts
Normal file
11
frontend/src/api/apiClient.ts
Normal 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
|
||||
21
frontend/src/api/authApi.ts
Normal file
21
frontend/src/api/authApi.ts
Normal 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
|
||||
}
|
||||
58
frontend/src/api/entitiesApi.ts
Normal file
58
frontend/src/api/entitiesApi.ts
Normal 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`)
|
||||
}
|
||||
27
frontend/src/api/logsApi.ts
Normal file
27
frontend/src/api/logsApi.ts
Normal 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
|
||||
}
|
||||
29
frontend/src/components/NavBar.tsx
Normal file
29
frontend/src/components/NavBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/ProtectedRoute.tsx
Normal file
22
frontend/src/components/ProtectedRoute.tsx
Normal 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
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
22
frontend/src/main.tsx
Normal file
22
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
8
frontend/src/pages/DashboardPage.tsx
Normal file
8
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/LoginPage.tsx
Normal file
12
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/router/index.tsx
Normal file
24
frontend/src/router/index.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
])
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
27
frontend/vite.config.ts
Normal 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
51
nginx/nginx.allinone.conf
Normal 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
51
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user