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:**
|
**Image tags pushed on every `main` merge:**
|
||||||
- `<dockerhub-user>/condado-newsletter:latest`
|
- `<dockerhub-user>/condado-newsletter:latest`
|
||||||
- `<dockerhub-user>/condado-newsletter:<git-sha>` (for pinning)
|
- `<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 |
|
| Step | Description | Status |
|
||||||
|------|-----------------------------------------|-------------|
|
|------|-----------------------------------------|-------------|
|
||||||
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
| 0 | Define project & write CLAUDE.md | ✅ Done |
|
||||||
| 1 | Scaffold monorepo structure | ⬜ Pending |
|
| 1 | Scaffold monorepo structure | ✅ Done |
|
||||||
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
| 2 | Domain model (JPA entities) | ⬜ Pending |
|
||||||
| 3 | Repositories | ⬜ Pending |
|
| 3 | Repositories | ⬜ Pending |
|
||||||
| 4 | Email Reader Service (IMAP) | ⬜ 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."
|
> `.github/workflows/publish.yml`. Do not implement business logic yet — just the skeleton."
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
- [ ] `cd backend && ./gradlew build` compiles with no errors.
|
- [x] `cd backend && ./gradlew build` compiles with no errors.
|
||||||
- [ ] `cd frontend && npm install && npm run build` succeeds.
|
- [x] `cd frontend && npm install && npm run build` succeeds.
|
||||||
- [ ] Application starts with `./gradlew bootRun` (backend) without errors.
|
- [x] Application starts with `./gradlew bootRun` (backend) without errors.
|
||||||
- [ ] `npm run dev` starts the Vite dev server.
|
- [x] `npm run dev` starts the Vite dev server.
|
||||||
- [ ] `docker compose up --build` starts all containers.
|
- [ ] `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