Compare commits
183
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a8985b29 | ||
|
|
9073c52b5c | ||
|
|
6981734bda | ||
|
|
5f4f417edc | ||
|
|
3736cc73ae | ||
|
|
29027d4515 | ||
|
|
444ce804ed | ||
|
|
f2ab2b31ed | ||
|
|
bb7bef18d9 | ||
|
|
1047e839f5 | ||
|
|
87108a9ebd | ||
|
|
1e0a5c0af1 | ||
|
|
8184db0c46 | ||
|
|
65ff109800 | ||
|
|
48d120fbff | ||
|
|
43b6d227db | ||
|
|
fed981212a | ||
|
|
e310c1bbd8 | ||
|
|
9ae94c81e6 | ||
|
|
8f24163bc0 | ||
|
|
2ce90a6cac | ||
|
|
c363e16987 | ||
|
|
d5cf43ec08 | ||
|
|
6908f7530a | ||
|
|
573f0110c7 | ||
|
|
0508687cad | ||
|
|
baa0582d50 | ||
|
|
3086b7e2ca | ||
|
|
47466a6c61 | ||
|
|
28080c7954 | ||
|
|
23e4467e23 | ||
|
|
b08fbf3b03 | ||
|
|
51382d0bd1 | ||
|
|
a55270f6d5 | ||
|
|
7f6fd4259b | ||
|
|
46a86f15d8 | ||
|
|
78b6e04db6 | ||
|
|
3fed8a4ea2 | ||
|
|
5b0c2d6d02 | ||
|
|
ee2685aa07 | ||
|
|
12fc7909fd | ||
|
|
9bbf2fd098 | ||
|
|
9c2cbd1608 | ||
|
|
fffdd2a575 | ||
|
|
8c18d7ff73 | ||
|
|
4318fc13f6 | ||
|
|
a2e110d3dd | ||
|
|
adb51584e7 | ||
|
|
048120688f | ||
|
|
b356682175 | ||
|
|
bdbfece62c | ||
|
|
597c941994 | ||
|
|
8183bf61ea | ||
|
|
5838084b73 | ||
|
|
77ee6f68c6 | ||
|
|
5c58fe43d9 | ||
|
|
7652645f42 | ||
|
|
2ea346651f | ||
|
|
6b5a3b5687 | ||
|
|
68bc14198e | ||
|
|
33c560555e | ||
|
|
ff9cfe2b3c | ||
|
|
17e15279c5 | ||
|
|
40cbe7103f | ||
|
|
fb64a7787f | ||
|
|
38c72d1bb8 | ||
|
|
077308b547 | ||
|
|
6e3d3347e2 | ||
|
|
a1ce085123 | ||
|
|
61d399761d | ||
|
|
17a959e7a7 | ||
|
|
b7bc8f5525 | ||
|
|
a542d23c00 | ||
|
|
fad016cdaf | ||
|
|
dbf7a9c50d | ||
|
|
f0d536d8f4 | ||
|
|
77bc8b996b | ||
|
|
5776676eeb | ||
|
|
1a86c23565 | ||
|
|
ea866377bc | ||
|
|
a7b984ca84 | ||
|
|
f3fe9901c5 | ||
|
|
c461aa81cc | ||
|
|
856fa9e166 | ||
|
|
7ce01dff0b | ||
|
|
1ba85e129e | ||
|
|
c75bf2ad71 | ||
|
|
d91f39d087 | ||
|
|
f39bf049a0 | ||
|
|
5b2648d526 | ||
|
|
a8ecf65180 | ||
|
|
4d96524adb | ||
|
|
1d5006fd7e | ||
|
|
406a041ce9 | ||
|
|
d7233d817c | ||
|
|
92aaf63c12 | ||
|
|
ef09a3c84d | ||
|
|
463fbd8332 | ||
|
|
0cce4727e5 | ||
|
|
83427b4f6b | ||
|
|
4aa3d0134c | ||
|
|
0eb135249e | ||
|
|
1bada8d83e | ||
|
|
b55fcf19f2 | ||
|
|
a8e39d8f09 | ||
|
|
2c766b10a3 | ||
|
|
43bbcece7a | ||
|
|
0127706262 | ||
|
|
2cbb8b7467 | ||
|
|
4a256c8086 | ||
|
|
ae3e699938 | ||
|
|
7b21011f5a | ||
|
|
750dda897d | ||
|
|
01fa3e4267 | ||
|
|
a1ebb1be21 | ||
|
|
7de38afec2 | ||
|
|
a5c1931a2a | ||
|
|
3e80b81e07 | ||
|
|
ef4ee70aac | ||
|
|
00bb929f22 | ||
|
|
c553735653 | ||
|
|
680e5f0abd | ||
|
|
2000278f1a | ||
|
|
702e6bb973 | ||
|
|
8a5f7c2bf8 | ||
|
|
3e4a1f92b1 | ||
|
|
c59f2598b0 | ||
|
|
30784fa756 | ||
|
|
da0411f5d1 | ||
|
|
240a366ce8 | ||
|
|
9c3e1469c7 | ||
|
|
ba7887f6b2 | ||
|
|
8b1a604dc2 | ||
|
|
be0821b0be | ||
|
|
913f3c75f1 | ||
|
|
0169cf04b6 | ||
|
|
ab1d7e68f5 | ||
|
|
2387d41ebb | ||
|
|
c15c7a0d61 | ||
|
|
bda9391c75 | ||
|
|
6267e18478 | ||
|
|
79379b238a | ||
|
|
75b6995b94 | ||
|
|
8128ab829f | ||
|
|
8da3b14e40 | ||
|
|
55508a27bb | ||
|
|
892cbdacd2 | ||
|
|
c311e26a77 | ||
|
|
ae10480ed5 | ||
|
|
0c13b134ab | ||
|
|
84fe628656 | ||
|
|
f7cf4360c0 | ||
|
|
9228e335e4 | ||
|
|
e0739cf89a | ||
|
|
8e48941870 | ||
|
|
f20221fd8d | ||
|
|
3f579799af | ||
|
|
dae8b21c77 | ||
|
|
2b93165395 | ||
|
|
dfcb6cc2c1 | ||
|
|
99fe65fb57 | ||
|
|
dd7d468063 | ||
|
|
94d77e8843 | ||
|
|
89b4a297fe | ||
|
|
1aa413022f | ||
|
|
fd91198315 | ||
|
|
6df798c403 | ||
|
|
6d9942d532 | ||
|
|
0c88e3fd5a | ||
|
|
fcdc1d0b50 | ||
|
|
95c3362f0f | ||
|
|
66e1a32ade | ||
|
|
71d5f23e5f | ||
|
|
15831aefd7 | ||
|
|
0913bead4b | ||
|
|
ad9b75d620 | ||
|
|
e8e53f9508 | ||
|
|
c6d55277ee | ||
|
|
3941ad913b | ||
|
|
ec3db429ba | ||
|
|
f2d0975f20 | ||
|
|
3a9a85efaa | ||
|
|
8a2efef9e1 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Build and Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18.4
|
||||
env:
|
||||
POSTGRES_USER: vaessl
|
||||
POSTGRES_PASSWORD: vaessl
|
||||
POSTGRES_DB: vaessl_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "zulu" # Alternative distribution options are available.
|
||||
|
||||
- name: Make Gradle Executable
|
||||
run: chmod +x ./gradlew
|
||||
working-directory: backend
|
||||
|
||||
- name: Build and Analyze
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
|
||||
DB_URL: jdbc:postgresql://postgres:5432/vaessl_test
|
||||
DB_TEST_URL: jdbc:postgresql://postgres:5432/vaessl_test
|
||||
DB_USERNAME: vaessl
|
||||
DB_PASSWORD: vaessl
|
||||
PG_DRIVER_CLASS_NAME: org.postgresql.Driver
|
||||
|
||||
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
ALLOWED_ORIGINS: http://localhost:3000
|
||||
run: ./gradlew build sonar --info
|
||||
working-directory: backend
|
||||
@@ -0,0 +1 @@
|
||||
.vscode/
|
||||
@@ -0,0 +1,136 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Vaessl is an AI-powered integration bridge that accepts user text/image inputs, processes them through an LLM pipeline (via LiteLLM), and exports structured data to management systems (Homebox, WikiJS). The backend uses a provider pattern for extensibility. The frontend has a working connection management dashboard.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (Spring Boot + Gradle, inside `backend/`)
|
||||
|
||||
```bash
|
||||
./gradlew build # compile and package
|
||||
./gradlew test # run all tests
|
||||
./gradlew test --tests com.vaessl.app.connection.ConnectionServiceTest # single test class
|
||||
```
|
||||
|
||||
### Frontend (React + Vite, inside `frontend/`)
|
||||
|
||||
```bash
|
||||
npm run dev # start dev server
|
||||
npm run build # TypeScript check + Vite build
|
||||
npm run lint # ESLint
|
||||
npm run test # Vitest watch mode
|
||||
npm run test:ui # Vitest visual dashboard
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.local` (not committed) into `backend/` with:
|
||||
|
||||
- `DB_URL`, `DB_TEST_URL`, `DB_USERNAME`, `DB_PASSWORD` — PostgreSQL (test container on port 5434)
|
||||
- `PG_DRIVER_CLASS_NAME` — PostgreSQL JDBC driver class
|
||||
- `OPENAI_KEY`, `OPENAI_BASE_URL` — LiteLLM gateway (provider-agnostic, configured for gpt-4o-mini)
|
||||
- `FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL` — allowed CORS origins for the backend
|
||||
|
||||
Frontend (optional, defaults to `/api`):
|
||||
|
||||
- `VITE_API_URL` — backend base URL used by `api/client.ts`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`backend/src/main/java/com/vaessl/app/`)
|
||||
|
||||
Package-by-feature layout. Server context path is `/api`. Main endpoints:
|
||||
|
||||
- `POST /api/login` — authenticates a service, stores connection ID in session
|
||||
- `GET /api/connections/status` — lists connected services for the current session
|
||||
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
|
||||
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
|
||||
- `POST /api/sync` — triggers a vector-store sync for the requested service; returns 401 if no active session. Not yet triggered on login or called from the frontend — manual/internal trigger only for now.
|
||||
|
||||
Eight packages:
|
||||
|
||||
**`shared/`** — cross-cutting types used by more than one feature package
|
||||
|
||||
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used across `connection/`, `search/`, `sync/`
|
||||
- `ServiceProvider` (interface): base for `ConnectionProvider`, `SearchProvider`, `SyncProvider`; declares `getServiceType()`
|
||||
- `ServiceItem`: normalized item shape (`id`, `title`, `description`, `extraData`) returned by both search and sync fetches
|
||||
- `Endpoint` (enum): API path constants for all external service calls
|
||||
- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID`
|
||||
|
||||
**`config/`**
|
||||
|
||||
- `CorsConfig`: env-driven allowed origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`); `allowCredentials(true)` is required for session cookies to work cross-origin
|
||||
- `SessionConfig`: JDBC-backed Spring Session with a persistent cookie (`SameSite=Lax`, `HttpOnly`)
|
||||
|
||||
**`connection/`** — connecting to and persisting service credentials
|
||||
|
||||
- `ConnectionProvider` interface: extends `ServiceProvider`; each integrated app implements `login()` and credential checking
|
||||
- `ConnectionIdentifiable` interface: `appUrl()`/`username()`/`serviceType()`; implemented by `SearchRequest` and `SyncRequest` so `HomeboxItemClient` can resolve the underlying connection regardless of which feature is calling it
|
||||
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
||||
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
|
||||
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
||||
- `HomeboxConnectionProvider` / `HomeboxConnectionEntity`: Homebox-specific implementation
|
||||
|
||||
**`homebox/`** — shared Homebox API access
|
||||
|
||||
- `HomeboxItemClient`: fetches a page of items from the Homebox entities API and maps them to `ServiceItem`; used by both `HomeboxSearchProvider` (keyword search) and `HomeboxSyncProvider` (vector-store sync) so the fetch/mapping logic isn't duplicated
|
||||
|
||||
**`vector/`** — vectorization
|
||||
|
||||
- `EmbeddingService`: embeds `ServiceItem`s into pgvector's `VectorStore` and prunes entries that no longer exist upstream. Document ID is `{connectionId}:{itemId}`, so re-syncing upserts existing items instead of duplicating them. Deliberately stateless (only field is the injected `VectorStore`) since it's a singleton bean — the per-sync ID accumulator used for pruning is owned by the caller (`HomeboxSyncProvider`), not held as instance state
|
||||
- `PgVectorStore` itself is auto-configured by `spring-ai-starter-vector-store-pgvector` from `application.yaml` (`spring.ai.vectorstore.pgvector.dimensions`) — no manual config class in this codebase
|
||||
|
||||
**`search/`** — querying connected services (keyword and AI)
|
||||
|
||||
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
|
||||
- `SearchService`: dispatches by `ServiceType` via a single provider map — **does not yet route on `SearchRequest.aiSearch`**; `HomeboxSearchProvider` and `HomeboxAiSearchProvider` both currently register for `ServiceType.HOMEBOX`, so only one wins the registration (known gap, pending AI search work)
|
||||
- `SearchController`: guards with session check before delegating to `SearchService`
|
||||
- `SearchRequest`: includes `aiSearch: boolean` (not yet consumed, see above) and implements `ConnectionIdentifiable`
|
||||
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
|
||||
- `HomeboxSearchProvider`: keyword search; delegates the remote fetch to `HomeboxItemClient`
|
||||
- `HomeboxAiSearchProvider`: **stub only** — `getSearchResults` throws `UnsupportedOperationException`; the actual similarity search + summary generation is pending
|
||||
|
||||
**`sync/`** — indexing connected services into the vector store
|
||||
|
||||
- `SyncProvider` interface: extends `ServiceProvider`; each integrated app implements `syncVectorStore()`
|
||||
- `SyncService`: dispatches by `ServiceType` via a provider map, same pattern as `SearchService`
|
||||
- `SyncController`: session-gated `POST /sync`; not currently called by the frontend or triggered on login
|
||||
- `SyncRequest`: implements `ConnectionIdentifiable`
|
||||
- `HomeboxSyncProvider`: pages through the full Homebox catalog via `HomeboxItemClient`, embedding each page through `EmbeddingService.vectorizeData` and collecting every item ID seen along the way. Once the full page loop finishes, calls `EmbeddingService.deleteStaleVectorEntries` **once** with the complete ID set, removing any previously indexed item no longer present upstream. Order matters — pruning per page instead of once at the end would treat items on other pages as stale and delete them too.
|
||||
|
||||
**Pending work (not yet implemented):** `HomeboxAiSearchProvider`'s actual similarity search + summary generation; `SearchService` routing two provider maps by `aiSearch`; triggering `/sync` on login or from the frontend; a frontend sync button and AI search UI.
|
||||
|
||||
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components/`.
|
||||
|
||||
- `api/client.ts` — typed `apiFetch` wrapper; always sends `credentials: 'include'` for session cookies; base URL from `VITE_API_URL` (defaults to `/api`)
|
||||
- `api/connections.ts` — connection-specific API calls (`getStatuses`, `login`, `logout`)
|
||||
- `api/searches.ts` — search API call (`search`)
|
||||
- `types/connection.ts` — `ServiceType`, `LoginRequest`, `AuthResponse`, `ConnectionStatus`
|
||||
- `types/search.ts` — `SearchRequest`, `SearchResponse`, `PagedSearchResponse`
|
||||
- `components/connections/` — `Dashboard`, `ConnectModal`, `ServiceCard` (connection management UI)
|
||||
- `components/search/SearchModal` — search dialog; posts to `/api/search` and renders paged results
|
||||
- `components/ui/` — shared UI primitives (`ActionButton`, `Modal`)
|
||||
|
||||
### Data & AI
|
||||
|
||||
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
||||
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it — `OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
|
||||
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`, auto-configured from `application.yaml` (no manual config class)
|
||||
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
|
||||
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
Integration tests spin up a **mirrored PostgreSQL container** on port 5434 (same schema as production). WireMock mocks external HTTP APIs (Homebox, WikiJS). Do not mock the database in integration tests — the mirrored container strategy exists specifically to catch schema/migration divergence.
|
||||
|
||||
## Chat Operations
|
||||
|
||||
Don't make code suggestions and changes unless explicitly asked. Treat every prompt as a discussion of latest best practice coding approaches.
|
||||
@@ -0,0 +1,152 @@
|
||||
## Project Intent
|
||||
Vaessl is a **technical playground** and proof-of-concept designed to showcase a complete, professional-grade software lifecycle. The primary goal is not just to build a functional utility, but to demonstrate a multi-experience approach to full-stack engineering, DevOps, and AI integration.
|
||||
|
||||
This project serves as a transparent portfolio of my take on:
|
||||
* **Infrastructure:** Moving from local-machine to browser-based development environment hosted on my Proxmox server.
|
||||
* **Modern AI Orchestration:** Building a system that actually uses AI to do heavy lifting, like identifying objects and understanding natural language using Spring AI and LiteLLM.
|
||||
* **Architectural Foresight:** Designing with abstraction layers early to ensure the system can scale from a single-target tool to a multi-app bridge without significant refactoring.
|
||||
|
||||
---
|
||||
|
||||
## What is Vaessl?
|
||||
Vaessl acts as a translator between user inputs via text or image and digital management systems. It performs image and semantic search analysis before serving results from applications (e.g., Homebox, WikiJS) via REST API.
|
||||
|
||||
### Core Functionality
|
||||
* **Connection Management:** Users connect to supported services (e.g. Homebox) via a credential login flow. Tokens are stored in a JDBC-backed HTTP session, enabling secure multi-service connections per browser session.
|
||||
* **AI-Powered Analysis:** The system utilises **LiteLLM** as a unified gateway to process inputs via LLM of choice *(pipeline in progress)*.
|
||||
* **Semantic Discovery:** By utilising **Spring AI** and **pgvector**, Vaessl will store description embeddings to enable intent-based search (e.g., finding a "Wrench" by searching for "tool to fix a leaky pipe") *(planned)*.
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Spring Boot 4.1.0, Java 25 (LTS), Spring AI |
|
||||
| **Frontend** | React 19, Vite 8, SCSS |
|
||||
| **Database** | PostgreSQL + `pgvector` |
|
||||
| **AI Gateway** | LiteLLM (Unified API proxy) |
|
||||
| **DevOps** | Docker, Portainer, Hoppscotch (API testing), Gitea Actions, Jenkins |
|
||||
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Infrastructure & Methodology
|
||||
|
||||
### 1. Centralized Development (Code-Server)
|
||||
To maintain a zero-footprint local setup, I develop within a custom **code-server** Docker instance.
|
||||
* **Custom Image:** The environment is baked with a specific toolchain (OpenJDK 25, Node.js 24) to ensure absolute environment parity.
|
||||
* **Remote Access:** Secured via Pangolin tunnel, allowing for secure, remote development without exposing ports to the public internet.
|
||||
|
||||
### 2. Abstraction & Scalability
|
||||
A core requirement was to prevent vendor or application lock-in:
|
||||
* **Provider Pattern:** Both connection and search logic are fully abstracted behind `ConnectionProvider` and `SearchProvider` interfaces. Adding a new service (e.g. WikiJS) means implementing those interfaces — no changes to core dispatch logic required.
|
||||
* **AI Agnostic:** LiteLLM abstracts the AI provider, allowing the backend to switch between cloud APIs and local inference engines (Ollama) without code changes.
|
||||
|
||||
### 3. Testing Strategy
|
||||
* **Database Parity:** Mirrored PostgreSQL containers are used for development and testing (`vaessl-db` vs `vaessl-test-db`). This ensures JPA operations are tested against the real PostgreSQL engine and `pgvector` extension, not mocks.
|
||||
* **WireMock:** External HTTP APIs (Homebox, WikiJS) are stubbed in integration tests using WireMock, isolating tests from live service availability.
|
||||
* **Frontend:** A Vitest-based stack provides a fast feedback loop for UI components.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Backend (`backend/src/main/java/com/vaessl/app/`)
|
||||
|
||||
Package-by-feature layout. Server context path is `/api`.
|
||||
|
||||
| Package | Purpose |
|
||||
| :--- | :--- |
|
||||
| `shared/` | Cross-cutting types: `ServiceType` enum, `ServiceProvider` base interface, `Endpoint` enum, `SessionKeys` utility |
|
||||
| `config/` | CORS (env-driven allowed origins) and JDBC-backed Spring Session |
|
||||
| `connection/` | Login, session management, credential persistence (Single Table Inheritance) |
|
||||
| `search/` | Paged search against connected services, guarded by active session |
|
||||
| `exception/` | `GlobalExceptionHandler` via `@ControllerAdvice`; domain exceptions mapped to typed HTTP responses |
|
||||
|
||||
**REST Endpoints:**
|
||||
|
||||
| Method | Path | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `POST` | `/api/login` | Authenticates a service and stores its connection ID in the session |
|
||||
| `GET` | `/api/connections/status` | Lists all connected services for the current session |
|
||||
| `DELETE` | `/api/connections/{serviceType}` | Removes a service from the session; invalidates the session if none remain |
|
||||
| `POST` | `/api/search` | Paged search against a connected service; returns 401 if no active session |
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
React 19 + TypeScript + SCSS, Vite 8 build. Package-by-feature under `components/`.
|
||||
|
||||
| Module | Description |
|
||||
| :--- | :--- |
|
||||
| `api/` | Typed `apiFetch` wrapper + per-feature API call modules (`connections.ts`, `searches.ts`) |
|
||||
| `types/` | Shared TS types aligned with backend records (`ServiceType`, `LoginRequest`, `SearchResponse`, etc.) |
|
||||
| `components/connections/` | `Dashboard`, `ConnectModal`, `ServiceCard` — full connection management UI |
|
||||
| `components/search/` | `SearchModal` — search dialog with paged result rendering |
|
||||
| `components/ui/` | Shared primitives (`ActionButton`, `Modal`) |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
* [x] Deployment of core infrastructure (PostgreSQL, LiteLLM, Hoppscotch via Docker).
|
||||
* [x] Custom Code-Server image build and deployment.
|
||||
* [x] Spring Boot skeleton with Java 25 and Gradle Kotlin DSL.
|
||||
|
||||
### Phase 2: Connection & Basic Search (Current)
|
||||
* [x] Provider-pattern abstraction for connections and search.
|
||||
* [x] Homebox authentication — login, session-tracked token, expiry checking.
|
||||
* [x] Paged search against Homebox entities via REST.
|
||||
* [x] React connection management dashboard (connect, disconnect, status).
|
||||
* [x] React search modal with paged results.
|
||||
* [x] Integration test suite (WireMock + mirrored PostgreSQL container).
|
||||
* [ ] Image/text input → LLM inference → staging table workflow.
|
||||
* [ ] Data refinement UI for reviewing and approving LLM-processed results.
|
||||
|
||||
### Phase 3: Semantic Search & Expansion
|
||||
* [ ] Spring AI vector embeddings with pgvector for intent-based search.
|
||||
* [ ] Additional API targets (WikiJS).
|
||||
* [ ] Public-facing demo.
|
||||
|
||||
---
|
||||
|
||||
## Setup & Deployment
|
||||
|
||||
1. Clone the repository.
|
||||
2. Copy `.env.local` into `backend/` with:
|
||||
|
||||
```env
|
||||
DB_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl
|
||||
DB_TEST_URL=jdbc:postgresql://192.168.1.{subnet}:{port}/vaessl_test
|
||||
DB_USERNAME={username}
|
||||
DB_PASSWORD={password}
|
||||
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
|
||||
OPENAI_KEY={api-key}
|
||||
OPENAI_BASE_URL=https://{litellm-host}/v1
|
||||
FRONTEND_LOCAL_URL=http://localhost:5173
|
||||
FRONTEND_PUBLIC_URL=https://{your-public-domain}
|
||||
```
|
||||
|
||||
3. Start the pgvector-enabled PostgreSQL instances (production + test) — use the official `pgvector/pgvector` Docker image or install the extension into your existing PostgreSQL instance.
|
||||
4. Install frontend dependencies and build the backend:
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm install
|
||||
|
||||
# Backend
|
||||
cd backend && ./gradlew build
|
||||
```
|
||||
|
||||
5. Run the backend and frontend dev servers:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && ./gradlew bootRun
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -0,0 +1,40 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Misc ###
|
||||
*.local
|
||||
@@ -0,0 +1,97 @@
|
||||
val wiremockVersion = "3.12.0"
|
||||
val postgresqlVersion = "42.7.11"
|
||||
val springAiVersion by extra("2.0.0")
|
||||
|
||||
plugins {
|
||||
java
|
||||
jacoco
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("org.sonarqube") version "7.3.0.8198"
|
||||
}
|
||||
|
||||
group = "com.vaessl"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "Vaessl")
|
||||
property("sonar.projectName", "Vaessl")
|
||||
property("sonar.coverage.jacoco.xmlReportPaths",
|
||||
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom("org.springframework.ai:spring-ai-bom:$springAiVersion")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot Starters
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||
|
||||
// Spring AI Starters (versions managed by spring-ai-bom)
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
|
||||
|
||||
// Database
|
||||
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||
|
||||
// Tooling & Code Generation
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
// Testing
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
|
||||
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.compilerArgs.add("-parameters")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
finalizedBy(tasks.jacocoTestReport)
|
||||
testLogging {
|
||||
events("failed", "standardError")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
|
||||
tasks.jacocoTestReport {
|
||||
dependsOn(tasks.withType<Test>())
|
||||
reports {
|
||||
xml.required = true
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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
|
||||
|
||||
|
||||
|
||||
# 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" )
|
||||
|
||||
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='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_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" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# 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" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@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
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@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="-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
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
: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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "app"
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vaessl.app;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vaessl.app.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${vaessl.allowed-origins}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**").allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("Content-Type", "Accept").allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.vaessl.app.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
@Configuration
|
||||
public class SessionConfig {
|
||||
|
||||
@Bean
|
||||
public CookieSerializer cookieSerializer() {
|
||||
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||
serializer.setCookieMaxAge(Integer.MAX_VALUE);
|
||||
serializer.setUseHttpOnlyCookie(true);
|
||||
serializer.setSameSite("Lax");
|
||||
return serializer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
public record AuthResponse(ServiceType serviceType, Instant expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import com.vaessl.app.shared.SessionKeys;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class ConnectionController {
|
||||
|
||||
private final ConnectionService connectionService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody ConnectionRequest request,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
LoginResult result = connectionService.login(request);
|
||||
|
||||
HttpSession session = httpReq.getSession(true);
|
||||
session.setAttribute(SessionKeys.connectionId(request.serviceType()), result.connectionId());
|
||||
|
||||
if (result.expiresAt() != null) {
|
||||
long secs = Instant.now().until(result.expiresAt(), ChronoUnit.SECONDS);
|
||||
session.setMaxInactiveInterval((int) Math.max(secs, 300));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(new AuthResponse(request.serviceType(), result.expiresAt()));
|
||||
}
|
||||
|
||||
@GetMapping("/connections/status")
|
||||
public ResponseEntity<List<ConnectionStatusResponse>> getStatus(HttpServletRequest httpReq) {
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
if (session == null) {
|
||||
return ResponseEntity.ok(List.of());
|
||||
}
|
||||
|
||||
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
||||
Collections.list(session.getAttributeNames()).stream()
|
||||
.filter(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX))
|
||||
.forEach(k -> {
|
||||
String name = k.replace(SessionKeys.CONNECTION_ID_SUFFIX, "");
|
||||
try {
|
||||
ServiceType serviceType = ServiceType.valueOf(name);
|
||||
Long id = (Long) session.getAttribute(k);
|
||||
ConnectionStatusResponse status =
|
||||
connectionService.getConnectionStatus(serviceType, id);
|
||||
if (status != null)
|
||||
statuses.add(status);
|
||||
} catch (IllegalArgumentException _) {
|
||||
// stale session key from an old or removed service type — skip it
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(statuses);
|
||||
}
|
||||
|
||||
@DeleteMapping("/connections/{serviceType}")
|
||||
public ResponseEntity<Void> logout(@PathVariable("serviceType") ServiceType serviceType,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
if (session != null) {
|
||||
session.removeAttribute(SessionKeys.connectionId(serviceType));
|
||||
boolean hasMore = Collections.list(session.getAttributeNames()).stream()
|
||||
.anyMatch(k -> k.endsWith(SessionKeys.CONNECTION_ID_SUFFIX));
|
||||
if (!hasMore) {
|
||||
session.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import jakarta.persistence.DiscriminatorColumn;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Inheritance;
|
||||
import jakarta.persistence.InheritanceType;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "connections",
|
||||
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(name = "service_type")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public abstract class ConnectionEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String appUrl;
|
||||
private String username;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
/**
|
||||
* Identifies the connection a request targets. Implemented by request records
|
||||
* (e.g. {@code SearchRequest}, {@code SyncRequest}) so a single {@code HomeboxItemClient} can
|
||||
* resolve the underlying connection regardless of which feature is calling it.
|
||||
*/
|
||||
public interface ConnectionIdentifiable {
|
||||
|
||||
String appUrl();
|
||||
|
||||
String username();
|
||||
|
||||
ServiceType serviceType();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
public interface ConnectionProvider extends ServiceProvider {
|
||||
|
||||
void checkCredentials(ConnectionRequest request);
|
||||
|
||||
ConnectionResponse authenticate(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
||||
|
||||
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
||||
|
||||
default Instant getTokenExpiry(ConnectionEntity entity) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ConnectionRepository extends JpaRepository<ConnectionEntity, Long> {
|
||||
|
||||
ConnectionEntity findByAppUrlAndUsername(String appUrl, String username);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") String appUrl,
|
||||
@NotNull(message = "Service type is mandatory") ServiceType serviceType,
|
||||
String username, String password, String apiKey,
|
||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||
|
||||
public ConnectionRequest {
|
||||
if (stayLoggedIn == null) {
|
||||
stayLoggedIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionRequest(String appUrl, ServiceType serviceType, String username,
|
||||
String password, Boolean stayLoggedIn) {
|
||||
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record ConnectionResponse(String token, Instant expiresAt,
|
||||
Map<String, Object> extraResponseData) {
|
||||
|
||||
public String getExtraVar(String key) {
|
||||
if (extraResponseData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraResponseData.get(key);
|
||||
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
private final Map<ServiceType, ConnectionProvider> providerRegistry;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
|
||||
Map<ServiceType, ConnectionProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (ConnectionProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
public LoginResult login(ConnectionRequest request) {
|
||||
|
||||
ConnectionProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
provider.checkCredentials(request);
|
||||
|
||||
ConnectionResponse response = provider.authenticate(request);
|
||||
|
||||
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
|
||||
|
||||
ConnectionEntity saved;
|
||||
if (existing != null) {
|
||||
provider.updateToRepository(existing, response);
|
||||
saved = existing;
|
||||
} else {
|
||||
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
|
||||
saved = cRepository.save(newEntity);
|
||||
}
|
||||
|
||||
return new LoginResult(saved.getId(), response.expiresAt());
|
||||
}
|
||||
|
||||
public ConnectionStatusResponse getConnectionStatus(ServiceType serviceType,
|
||||
Long connectionId) {
|
||||
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
|
||||
if (entity == null)
|
||||
return null;
|
||||
|
||||
ConnectionProvider provider = providerRegistry.get(serviceType);
|
||||
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
||||
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
||||
|
||||
return new ConnectionStatusResponse(serviceType.name(), entity.getAppUrl(),
|
||||
entity.getUsername(), expiresAt, connected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
|
||||
Instant expiresAt, boolean connected) {
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.DiscriminatorValue;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@DiscriminatorValue("HOMEBOX")
|
||||
@Getter
|
||||
@Setter
|
||||
public class HomeboxConnectionEntity extends ConnectionEntity {
|
||||
|
||||
private String token;
|
||||
private String attachmentToken;
|
||||
private Instant expiresAt;
|
||||
|
||||
public static HomeboxConnectionEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||
|
||||
HomeboxConnectionEntity he = new HomeboxConnectionEntity();
|
||||
|
||||
he.setAppUrl(request.appUrl());
|
||||
he.setUsername(request.username());
|
||||
he.setToken(response.token());
|
||||
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
he.setExpiresAt(response.expiresAt());
|
||||
|
||||
return he;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
import com.vaessl.app.exception.RemoteApiException;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
@Override
|
||||
public void checkCredentials(ConnectionRequest request) {
|
||||
if (request.username() == null || request.password() == null) {
|
||||
List<String> missingFields = new ArrayList<>();
|
||||
|
||||
if (request.username() == null) {
|
||||
missingFields.add("username");
|
||||
}
|
||||
if (request.password() == null) {
|
||||
missingFields.add("password");
|
||||
|
||||
}
|
||||
throw new EmptyCredentialsException(List.copyOf(missingFields));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||
Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
|
||||
request.password(), "stayLoggedIn", request.stayLoggedIn());
|
||||
|
||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().post()
|
||||
.uri(HOMEBOX_LOGIN.getValue()).body(homeboxPayload).retrieve()
|
||||
.body(HomeboxLoginResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new RemoteApiException(request.appUrl(), HOMEBOX_LOGIN.getValue());
|
||||
}
|
||||
|
||||
Map<String, Object> attachmentToken = new HashMap<>();
|
||||
|
||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||
|
||||
String hbRawToken = hbResponse.token();
|
||||
|
||||
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
|
||||
|
||||
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
|
||||
|
||||
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request,
|
||||
ConnectionResponse response) {
|
||||
return HomeboxConnectionEntity.from(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||
|
||||
if (existing instanceof HomeboxConnectionEntity hbE) {
|
||||
|
||||
hbE.setToken(response.token());
|
||||
hbE.setExpiresAt(response.expiresAt());
|
||||
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
|
||||
cRepository.save(hbE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getTokenExpiry(ConnectionEntity entity) {
|
||||
return (entity instanceof HomeboxConnectionEntity he) ? he.getExpiresAt() : null;
|
||||
}
|
||||
|
||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginResult(Long connectionId, Instant expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
public class ConnectionNotFoundException extends RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class EmptyCredentialsException extends RuntimeException {
|
||||
|
||||
private final List<String> missingFields;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum ErrorMessage {
|
||||
BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST,
|
||||
"Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN(HttpStatus.UNAUTHORIZED,
|
||||
"Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"The target URL is unreachable."), SERVER_ERROR_GENERAL(
|
||||
"The external app returned a server error: "), WRONG_SERVICE_TYPE(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"No such service type."), CONNECTION_NOT_FOUND(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"Remote API returned empty response for "), REMOTE_API_CLIENT_ERROR(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"Remote API returned an unexpected error: ");
|
||||
|
||||
private final HttpStatus status;
|
||||
private final String message;
|
||||
|
||||
private ErrorMessage(HttpStatus status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
private ErrorMessage(String message) {
|
||||
this.status = null;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import static com.vaessl.app.exception.ErrorMessage.*;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||
|
||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(field -> field.getDefaultMessage()).collect(Collectors.joining(", "));
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]");
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpClientErrorException.Unauthorized.class)
|
||||
public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) {
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(),
|
||||
UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
}
|
||||
|
||||
// Catches any other 4xx from a remote API (e.g. 404 caused by a changed endpoint).
|
||||
// Must come after the Unauthorized handler so Spring matches 401 there first.
|
||||
@ExceptionHandler(HttpClientErrorException.class)
|
||||
public ProblemDetail handleRemoteClientError(HttpClientErrorException e) {
|
||||
log.error("Remote API returned {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return ProblemDetail.forStatusAndDetail(REMOTE_API_CLIENT_ERROR.getStatus(),
|
||||
REMOTE_API_CLIENT_ERROR.getMessage() + e.getStatusCode());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResourceAccessException.class)
|
||||
public ProblemDetail handleNoConnection(ResourceAccessException e) {
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(),
|
||||
SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpServerErrorException.class)
|
||||
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
||||
log.error("Remote API server error {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
|
||||
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
||||
}
|
||||
|
||||
@ExceptionHandler(WrongServiceTypeException.class)
|
||||
public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) {
|
||||
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(),
|
||||
WRONG_SERVICE_TYPE.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmptyCredentialsException.class)
|
||||
public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) {
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConnectionNotFoundException.class)
|
||||
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
|
||||
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(),
|
||||
CONNECTION_NOT_FOUND.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(RemoteApiException.class)
|
||||
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
|
||||
log.error("Remote API empty body: {}{}", e.getAppUrl(), e.getPath());
|
||||
return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(),
|
||||
REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl() + e.getPath());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class RemoteApiException extends RuntimeException {
|
||||
|
||||
private final String appUrl;
|
||||
private final String path;
|
||||
|
||||
public RemoteApiException(String appUrl, String path) {
|
||||
super("Remote API returned empty body: " + appUrl + path);
|
||||
this.appUrl = appUrl;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
public class WrongServiceTypeException extends RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.vaessl.app.homebox;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import com.vaessl.app.connection.ConnectionEntity;
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.connection.ConnectionRepository;
|
||||
import com.vaessl.app.connection.HomeboxConnectionEntity;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.exception.RemoteApiException;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
|
||||
/**
|
||||
* Fetches items from the Homebox entities API. Shared by {@code HomeboxSearchProvider} and
|
||||
* {@code HomeboxSyncProvider} so both keyword search and vector-store sync page through the same
|
||||
* remote call and item mapping.
|
||||
*/
|
||||
@Component
|
||||
public class HomeboxItemClient {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
|
||||
public HomeboxItemClient(RestClient.Builder restClienBuilder,
|
||||
ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClienBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of items from Homebox for the given connection.
|
||||
*
|
||||
* @param connection identifies which stored connection (app URL, username) to query
|
||||
* @param query optional keyword filter; {@code null} returns all items for the page
|
||||
* @param pageable page number and size to request
|
||||
* @return the mapped page of items along with the resolved connection ID
|
||||
* @throws com.vaessl.app.exception.ConnectionNotFoundException if no matching connection is
|
||||
* stored
|
||||
* @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body
|
||||
*/
|
||||
public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query,
|
||||
Pageable pageable) {
|
||||
ConnectionEntity entity =
|
||||
cRepository.findByAppUrlAndUsername(connection.appUrl(), connection.username());
|
||||
|
||||
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
|
||||
throw new ConnectionNotFoundException();
|
||||
}
|
||||
|
||||
HomeboxItemsResponse response = restClientBuilder.baseUrl(connection.appUrl()).build().get()
|
||||
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue()).queryParam("q", query)
|
||||
.queryParam("page", pageable.getPageNumber() + 1)
|
||||
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
|
||||
.body(HomeboxItemsResponse.class);
|
||||
|
||||
if (response == null) {
|
||||
throw new RemoteApiException(connection.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
|
||||
}
|
||||
|
||||
List<ServiceItem> items = response.items().stream().map(i -> {
|
||||
String id = i.id();
|
||||
String title = i.name();
|
||||
String description = i.description();
|
||||
|
||||
HomeboxParent parent = i.parent();
|
||||
Map<String, Map<String, Object>> extraData = new LinkedHashMap<>();
|
||||
Map<String, Object> locationData = new LinkedHashMap<>();
|
||||
|
||||
if (parent.name() != null && !parent.name().isBlank()) {
|
||||
locationData.put("name", parent.name());
|
||||
}
|
||||
if (parent.description() != null && !parent.description().isBlank()) {
|
||||
locationData.put("description", parent.description());
|
||||
}
|
||||
|
||||
if (locationData != null && !locationData.isEmpty()) {
|
||||
extraData.put("location", locationData);
|
||||
}
|
||||
|
||||
return new ServiceItem(id, title, description, extraData);
|
||||
}).toList();
|
||||
|
||||
return new HomeboxItemPage(new PageImpl<>(items, pageable, response.total()),
|
||||
hbEntity.getId());
|
||||
|
||||
}
|
||||
|
||||
public record HomeboxItemPage(Page<ServiceItem> page, Long connectionId) {
|
||||
}
|
||||
|
||||
private record HomeboxItemsResponse(int page, int pageSize, int total,
|
||||
List<HomeboxItem> items) {
|
||||
}
|
||||
|
||||
private record HomeboxItem(String id, String name, String description, HomeboxParent parent) {
|
||||
}
|
||||
|
||||
private record HomeboxParent(String name, String description) {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
@Component
|
||||
public class HomeboxAiSearchProvider implements SearchProvider {
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||
// TODO Auto-generated method stub
|
||||
throw new UnsupportedOperationException("Unimplemented method 'getSearchResults'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HomeboxSearchProvider implements SearchProvider {
|
||||
|
||||
private final HomeboxItemClient client;
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||
return client.hbResponse(request, request.query(), pageable).page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
||||
boolean first, boolean last, String sort, String summary) {
|
||||
|
||||
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
||||
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
||||
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
||||
pageResult.isLast(), pageResult.getSort().toString(), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.SessionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
/**
|
||||
* Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if
|
||||
* there is no active session.
|
||||
*/
|
||||
@PostMapping("/search")
|
||||
public ResponseEntity<PagedSearchResponse<ServiceItem>> search(
|
||||
@Valid @RequestBody SearchRequest request,
|
||||
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
if (session == null
|
||||
|| session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
Page<ServiceItem> result = searchService.search(request, pageable);
|
||||
|
||||
return ResponseEntity.ok(PagedSearchResponse.from(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
/**
|
||||
* Implemented by any service that supports querying its remote API for items.
|
||||
*/
|
||||
public interface SearchProvider extends ServiceProvider {
|
||||
|
||||
/**
|
||||
* Executes a search query against the remote service and returns matching results.
|
||||
*
|
||||
* @param request the search request containing the query string, app URL, and user credentials
|
||||
* @param pageable the Pageable interface
|
||||
* @return a list of Page<SearchResponse> items matching the query
|
||||
*/
|
||||
Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
||||
@NotNull ServiceType serviceType, boolean aiSearch) implements ConnectionIdentifiable {
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
private final Map<ServiceType, SearchProvider> providerRegistry;
|
||||
|
||||
public SearchService(List<SearchProvider> providers) {
|
||||
Map<ServiceType, SearchProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (SearchProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the paged search request to the provider registered for
|
||||
* {@link SearchRequest#serviceType()}.
|
||||
*
|
||||
* @param request the search request
|
||||
* @param pageable the Pageable interface
|
||||
* @return results returned by the matching provider
|
||||
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||
*/
|
||||
public Page<ServiceItem> search(SearchRequest request, Pageable pageable) {
|
||||
|
||||
SearchProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
return provider.getSearchResults(request, pageable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public enum Endpoint {
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
|
||||
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS(
|
||||
"/api/v1/entities"), SEARCH("/search"), SYNC("/sync");
|
||||
|
||||
private final String value;
|
||||
|
||||
private Endpoint(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
import java.util.Map;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ServiceItem(String id, @NotNull String title, String description,
|
||||
Map<String, Map<String, Object>> extraData) {
|
||||
|
||||
public String getExtra(String key) {
|
||||
if (extraData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraData.get(key);
|
||||
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public interface ServiceProvider {
|
||||
|
||||
ServiceType getServiceType();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public enum ServiceType {
|
||||
HOMEBOX;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vaessl.app.shared;
|
||||
|
||||
public final class SessionKeys {
|
||||
|
||||
public static final String CONNECTION_ID_SUFFIX = "_CONNECTION_ID";
|
||||
|
||||
private SessionKeys() {}
|
||||
|
||||
public static String connectionId(ServiceType serviceType) {
|
||||
return serviceType.name() + CONNECTION_ID_SUFFIX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient.HomeboxItemPage;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import com.vaessl.app.vector.EmbeddingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Pages through the full Homebox catalog and re-indexes it into the vector store. Item IDs are
|
||||
* collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries} is called
|
||||
* once at the end, rather than after each page: deleting per page would treat every item outside
|
||||
* the current page as stale, wiping out entries from pages already synced.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HomeboxSyncProvider implements SyncProvider {
|
||||
|
||||
private final EmbeddingService embeddingService;
|
||||
|
||||
private final HomeboxItemClient client;
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncVectorStore(SyncRequest request) {
|
||||
|
||||
int batchSize = 100;
|
||||
int page = 0;
|
||||
Page<ServiceItem> current;
|
||||
Long connectionId = null;
|
||||
List<String> currentItemIds = new ArrayList<>();
|
||||
do {
|
||||
Pageable pageable = PageRequest.of(page, batchSize);
|
||||
HomeboxItemPage result = client.hbResponse(request, null, pageable);
|
||||
current = result.page();
|
||||
if (connectionId == null) {
|
||||
connectionId = result.connectionId();
|
||||
}
|
||||
embeddingService.vectorizeData(current.getContent(), request.serviceType(),
|
||||
result.connectionId(), currentItemIds);
|
||||
page++;
|
||||
} while (page < current.getTotalPages());
|
||||
|
||||
embeddingService.deleteStaleVectorEntries(connectionId, currentItemIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.vaessl.app.shared.SessionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@RestController
|
||||
public class SyncController {
|
||||
|
||||
private final SyncService syncService;
|
||||
|
||||
public SyncController(SyncService syncService) {
|
||||
this.syncService = syncService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a vector-store sync for the requested service. Returns {@code 401 Unauthorized}
|
||||
* if there is no active session.
|
||||
*/
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<Object> syncVectorStore(@Valid @RequestBody SyncRequest request,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
|
||||
if (session == null
|
||||
|| session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
syncService.syncServiceVectorStore(request);
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
/**
|
||||
* Implemented by any service that supports indexing its items into the vector store.
|
||||
*/
|
||||
public interface SyncProvider extends ServiceProvider {
|
||||
|
||||
/**
|
||||
* Fetches all items from the remote service and indexes them into the vector store,
|
||||
* removing any previously indexed items that no longer exist upstream.
|
||||
*
|
||||
* @param request the sync request containing the app URL and user credentials
|
||||
*/
|
||||
public void syncVectorStore(SyncRequest request);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SyncRequest(@NotBlank String appUrl, @NotBlank String username,
|
||||
@NotNull ServiceType serviceType) implements ConnectionIdentifiable {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
@Service
|
||||
public class SyncService {
|
||||
|
||||
private final Map<ServiceType, SyncProvider> providerRegistry;
|
||||
|
||||
public SyncService(List<SyncProvider> providers) {
|
||||
Map<ServiceType, SyncProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (SyncProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the vector-store sync request to the provider registered for
|
||||
* {@link SyncRequest#serviceType()}.
|
||||
*
|
||||
* @param request the sync request
|
||||
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||
*/
|
||||
public void syncServiceVectorStore(SyncRequest request) {
|
||||
|
||||
SyncProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
provider.syncVectorStore(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.vaessl.app.vector;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer exist
|
||||
* upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is a
|
||||
* singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmbeddingService {
|
||||
|
||||
private final VectorStore vectorStore;
|
||||
|
||||
/**
|
||||
* Embeds and upserts {@code items} into the vector store, appending each item's ID to
|
||||
* {@code currentItemIds} so the caller can later prune stale entries via
|
||||
* {@link #deleteStaleVectorEntries}. Safe to call repeatedly (e.g. once per page) against the
|
||||
* same accumulator across a single sync run.
|
||||
*
|
||||
* @param items the items to embed for this batch
|
||||
* @param serviceType the originating service, stored in each document's metadata
|
||||
* @param connectionId the connection these items belong to
|
||||
* @param currentItemIds accumulator collecting every item ID seen so far in this sync run
|
||||
*/
|
||||
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId,
|
||||
List<String> currentItemIds) {
|
||||
List<Document> documents = new ArrayList<>();
|
||||
|
||||
for (ServiceItem item : items) {
|
||||
documents.add(toDocument(item, serviceType, connectionId));
|
||||
currentItemIds.add(item.id());
|
||||
}
|
||||
vectorStore.add(documents);
|
||||
}
|
||||
|
||||
private Document toDocument(ServiceItem item, ServiceType serviceType, Long connectionId) {
|
||||
String extraData = buildExtraData(item.extraData());
|
||||
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId);
|
||||
String content = buildContent(item, extraData);
|
||||
return new Document(connectionId + ":" + item.id(), content, metadata);
|
||||
}
|
||||
|
||||
private String buildExtraData(Map<String, Map<String, Object>> extraData) {
|
||||
if (extraData == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder formattedData = new StringBuilder();
|
||||
|
||||
for (Entry<String, Map<String, Object>> entry : extraData.entrySet()) {
|
||||
StringBuilder data = new StringBuilder();
|
||||
if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.getValue().entrySet() != null
|
||||
&& !entry.getValue().entrySet().toString().isEmpty()) {
|
||||
for (Entry<String, Object> nestedEntry : entry.getValue().entrySet()) {
|
||||
data.append(" ").append(nestedEntry.getKey()).append(": ")
|
||||
.append(nestedEntry.getValue()).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
formattedData.append(entry.getKey()).append(": ").append("\n").append(data.toString());
|
||||
}
|
||||
return formattedData.toString();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
|
||||
Long connectionId) {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("connectionId", connectionId);
|
||||
metadata.put("serviceType", serviceType.name());
|
||||
metadata.put("itemId", item.id());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String buildContent(ServiceItem item, String extraData) {
|
||||
StringBuilder content = new StringBuilder("title: ").append(item.title());
|
||||
if (item.description() != null && !item.description().isEmpty()) {
|
||||
content.append("\ndescription: ").append(item.description());
|
||||
}
|
||||
if (!extraData.isEmpty()) {
|
||||
|
||||
content.append("\n").append(extraData);
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every vector for {@code connectionId} whose item ID is not in {@code currentItemIds},
|
||||
* then clears the accumulator. Must be called once, after every page for this sync run has gone
|
||||
* through {@link #vectorizeData}, not per page — otherwise items from pages other than the most
|
||||
* recent one would look stale and get deleted too.
|
||||
*
|
||||
* @param connectionId the connection to prune stale vectors for
|
||||
* @param currentItemIds every item ID seen across the full sync run; cleared after this call
|
||||
*/
|
||||
public void deleteStaleVectorEntries(Long connectionId, List<String> currentItemIds) {
|
||||
FilterExpressionBuilder b = new FilterExpressionBuilder();
|
||||
vectorStore.delete(b.and(b.eq("connectionId", connectionId),
|
||||
b.nin("itemId", new ArrayList<>(currentItemIds))).build());
|
||||
|
||||
currentItemIds.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{"properties": [
|
||||
{
|
||||
"name": "spring.session.store-type",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'spring.session.store-type'"
|
||||
},
|
||||
{
|
||||
"name": "vaessl.allowed-origins",
|
||||
"type": "java.lang.String",
|
||||
"description": "Comma-separated list of allowed CORS origins"
|
||||
}
|
||||
]}
|
||||
@@ -0,0 +1,39 @@
|
||||
spring:
|
||||
application:
|
||||
name: vaessl
|
||||
config:
|
||||
import:
|
||||
- "optional:file:.env.local[.properties]"
|
||||
- "optional:file:backend/.env.local[.properties]"
|
||||
- "optional:file:vaessl/backend/.env.local[.properties]"
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: true
|
||||
ai:
|
||||
openai:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
api-key: ${OPENAI_KEY}
|
||||
chat:
|
||||
model: gpt-4o-mini
|
||||
vectorstore:
|
||||
pgvector:
|
||||
id-type: text
|
||||
dimensions: 1536
|
||||
distance-type: COSINE_DISTANCE
|
||||
index-type: HNSW
|
||||
initialize-schema: true
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
server:
|
||||
servlet:
|
||||
context-path: /api
|
||||
vaessl:
|
||||
allowed-origins: ${ALLOWED_ORIGINS}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.vaessl.app;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class ApplicationTests {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void contextLoads() {}
|
||||
|
||||
@Test
|
||||
void connectionToTestDbWorks() throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
assertThat(connection.getMetaData().getURL()).contains("vaessl_test");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.vaessl.app;
|
||||
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
public final class Mockdata {
|
||||
|
||||
private Mockdata() {}
|
||||
|
||||
public static final String MOCK_URL = "http://localhost:1234";
|
||||
public static final ServiceType MOCK_SERVICE_TYPE = ServiceType.HOMEBOX;
|
||||
public static final String MOCK_USER = "user";
|
||||
public static final String MOCK_PASS = "pw";
|
||||
public static final String MOCK_ID = "item-1";
|
||||
public static final String MOCK_TITLE = "title";
|
||||
public static final String MOCK_DESCRIPTION = "desc";
|
||||
public static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
|
||||
{
|
||||
"token": "fake-bearer-token",
|
||||
"attachmentToken": "fake-attach-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
public static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
|
||||
{
|
||||
"page": -1,
|
||||
"pageSize": -1,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
|
||||
"assetId": "000-001",
|
||||
"name": "MacBook Pro A1398",
|
||||
"description": "Running Linux (Fedora)",
|
||||
"quantity": 1,
|
||||
"insured": false,
|
||||
"archived": false,
|
||||
"createdAt": "2026-05-13T19:52:20.016176Z",
|
||||
"updatedAt": "2026-05-14T12:39:11.836403Z",
|
||||
"purchasePrice": 0,
|
||||
"parent": {
|
||||
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
|
||||
"name": "Server Schrank Ikea weiß",
|
||||
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
|
||||
"createdAt": "2026-05-13T19:55:55.817576Z",
|
||||
"updatedAt": "2026-05-14T12:37:24.396651Z"
|
||||
},
|
||||
"tags": [],
|
||||
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
|
||||
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
|
||||
"soldTime": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
import static com.vaessl.app.shared.ServiceType.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
class ConnectionControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
||||
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
||||
private static final String SESSION = "SESSION";
|
||||
|
||||
private static final String VALID_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "fake-jwt-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String EXPIRED_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "expired-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2000-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
||||
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm)
|
||||
throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name()))
|
||||
.andExpect(jsonPath("$[0].username").value(MOCK_USER))
|
||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||
.andExpect(jsonPath("$[0].connected").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm)
|
||||
throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.name()))
|
||||
.andExpect(jsonPath("$[0].connected").value(false))
|
||||
.andExpect(jsonPath("$[0].expiresAt")
|
||||
.value("2000-01-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie(SESSION);
|
||||
|
||||
// Verify connected before logout
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1));
|
||||
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
// A new request (no session cookie, as in a fresh browser) returns no connections
|
||||
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
||||
mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConnectionServiceTest {
|
||||
|
||||
@Mock
|
||||
private ConnectionProvider mockProvider;
|
||||
@Mock
|
||||
private ConnectionRepository mockRepo;
|
||||
|
||||
private ConnectionService connectionService;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE);
|
||||
connectionService = new ConnectionService(List.of(mockProvider), mockRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
||||
ConnectionRequest request =
|
||||
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||
|
||||
doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
|
||||
.checkCredentials(request);
|
||||
|
||||
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
||||
|
||||
verify(mockProvider, never()).authenticate(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.vaessl.app.exception.EmptyCredentialsException;
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HomeboxConnectionProviderTest {
|
||||
|
||||
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
|
||||
|
||||
@Test
|
||||
void checkCredentialsShouldThrowExceptionWhenFieldsAreMissing() {
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, HOMEBOX, null, null, false);
|
||||
|
||||
EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> {
|
||||
provider.checkCredentials(request);
|
||||
});
|
||||
|
||||
assertThat(exception.getMissingFields()).containsExactly("username", "password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import com.github.tomakehurst.wiremock.http.Fault;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
|
||||
import com.jayway.jsonpath.DocumentContext;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
import static com.vaessl.app.exception.ErrorMessage.*;
|
||||
import static com.vaessl.app.shared.ServiceType.*;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@AutoConfigureTestRestTemplate
|
||||
@WireMockTest
|
||||
class HomeboxIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
ConnectionRepository cRepository;
|
||||
|
||||
String okJsonHomeboxResponse = """
|
||||
{
|
||||
"token": "fake-jwt-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2026-04-26T02:23:13Z"
|
||||
}
|
||||
""";
|
||||
|
||||
/**
|
||||
* Returns Token and status code OK when login is successful.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
connectionRequest(wm), String.class);
|
||||
|
||||
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
||||
|
||||
String serviceType = documentContext.read("$.serviceType");
|
||||
assertThat(serviceType).isEqualTo("HOMEBOX");
|
||||
|
||||
String expiresAt = documentContext.read("$.expiresAt", String.class);
|
||||
assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the Unauthorized custom exception.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
connectionRequest(wm), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a server error from the external api.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
connectionRequest(wm), String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks when the service is unavailable or the app URL is wrong.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||
|
||||
ConnectionRequest badRequest = connectionRequest(wm);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
badRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody())
|
||||
.contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any login fields are empty since all of them are mandatory.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() {
|
||||
|
||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", null, "", "", false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
emtpyRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the exception when there is an input for serviceType but it's unsupported.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenServiceTypeIsInvalid(WireMockRuntimeInfo wm) {
|
||||
String body = """
|
||||
{"appUrl":"%s","serviceType":"wrong-service-type","username":"%s","password":"%s"}
|
||||
"""
|
||||
.formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<String> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(LOGIN.getValue(),
|
||||
HttpMethod.POST, entity, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the succesfull persistance of Homebox credential response to the database.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@Test
|
||||
void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue()))
|
||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ConnectionRequest request = connectionRequest(wm);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(),
|
||||
request.username());
|
||||
|
||||
assertThat(dbEntry).isNotNull();
|
||||
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
||||
assertThat(dbEntry.getUsername()).isEqualTo(request.username());
|
||||
|
||||
if (dbEntry instanceof HomeboxConnectionEntity hbE) {
|
||||
assertThat(hbE.getToken()).isEqualTo("fake-jwt-token");
|
||||
assertThat(hbE.getAttachmentToken()).isEqualTo("fake-attach");
|
||||
assertThat(hbE.getExpiresAt().toString())
|
||||
.hasToString("2026-04-26T02:23:13Z");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
||||
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||
HOMEBOX, MOCK_USER, null, false);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(),
|
||||
missingCredentials, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid connection request with a mock Api through WireMockRuntimeInfo.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
* @return a mock api connection request.
|
||||
*/
|
||||
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
|
||||
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX, MOCK_USER, MOCK_PASS,
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HomeboxSearchProviderTest {
|
||||
|
||||
@Mock
|
||||
private HomeboxItemClient client;
|
||||
|
||||
@InjectMocks
|
||||
private HomeboxSearchProvider provider;
|
||||
|
||||
@Test
|
||||
void shouldReturnConnectionNotFoundException() {
|
||||
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "", HOMEBOX, false);
|
||||
|
||||
when(client.hbResponse(request, "", pageable)).thenThrow(new ConnectionNotFoundException());
|
||||
|
||||
assertThrows(ConnectionNotFoundException.class,
|
||||
() -> provider.getSearchResults(request, pageable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.SEARCH;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
|
||||
/**
|
||||
* Integration tests for {@code POST /api/search}, verifying the session-gated contract and request
|
||||
* validation against a mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain
|
||||
* (via {@link MockMvc}).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
class SearchControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue();
|
||||
|
||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||
|
||||
private static final String SEARCH_REQUEST = SEARCH.getValue();
|
||||
|
||||
/**
|
||||
* Logs in against a stubbed Homebox instance, then performs a keyword search using the
|
||||
* resulting session cookie. Expects {@code 200 OK} with the mocked item mapped into the paged
|
||||
* response body.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
|
||||
* at the stub server
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(QUERY_ALL_ITEMS))
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(post(SEARCH_REQUEST).cookie(sessionCookie)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(searchRequestBody(wm, "HOMEBOX")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].extraData.location.name")
|
||||
.value("Server Schrank Ikea weiß"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code /api/search} with no session cookie attached and expects
|
||||
* {@code 401 Unauthorized}, confirming the endpoint is session-gated.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
|
||||
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{
|
||||
"appUrl": "http://irrelevant",
|
||||
"query": "Item",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "irrelevant",
|
||||
"aiSearch": false
|
||||
}
|
||||
""")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a search request with a {@code serviceType} that doesn't map to any known
|
||||
* {@link com.vaessl.app.shared.ServiceType} and expects {@code 400 Bad Request}.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception {
|
||||
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(searchRequestBody("INVALID_SERVICETYPE")))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
|
||||
return searchRequestBody(wm.getHttpBaseUrl(), serviceType, false);
|
||||
}
|
||||
|
||||
private String searchRequestBody(String serviceType) {
|
||||
return searchRequestBody("http://irrelevant", serviceType, false);
|
||||
}
|
||||
|
||||
private String searchRequestBody(String appUrl, String serviceType, boolean aiSearch) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"query": "Item",
|
||||
"serviceType": "%s",
|
||||
"username": "%s",
|
||||
"aiSearch": "%b"
|
||||
}
|
||||
""".formatted(appUrl, serviceType, MOCK_USER, aiSearch);
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_USER, MOCK_PASS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.Mockdata.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
|
||||
class SearchResponseTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnNullWhenExtraDataIsNull() {
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||
|
||||
assertThat(response.getExtra(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", Map.of("key", "value")));
|
||||
|
||||
assertThat(response.getExtra("missing")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnExtraDataValue() {
|
||||
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key",
|
||||
Map.of("key", "value")));
|
||||
|
||||
assertThat(response.id()).isEqualTo(MOCK_ID);
|
||||
assertThat(response.getExtra("key")).contains("value");
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
import static com.vaessl.app.shared.Endpoint.LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.SYNC;
|
||||
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||
import static com.vaessl.app.Mockdata.MOCK_SERVICE_TYPE;
|
||||
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Integration tests for {@code POST /api/sync}, verifying the session-gated contract against a
|
||||
* mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain (via {@link MockMvc}).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
class SyncControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String SYNC_PATH = SYNC.getValue();
|
||||
|
||||
/**
|
||||
* Logs in against a stubbed Homebox instance, then performs a sync using the resulting session
|
||||
* cookie. Expects a {@code 204 No Content} response once the mocked item catalog has been paged
|
||||
* through and embedded.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
|
||||
* at the stub server
|
||||
*/
|
||||
@Test
|
||||
void shouldReturn204NoContentOnSuccessfulSync(WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(HOMEBOX_QUERY_ALL_ITEMS.getValue()))
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN.getValue()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(post(SYNC_PATH).cookie(sessionCookie)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(syncRequestBody(wm)))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code /api/sync} with no session cookie attached and expects {@code 401 Unauthorized},
|
||||
* confirming the endpoint is session-gated.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used only to build a
|
||||
* well-formed request body
|
||||
*/
|
||||
@Test
|
||||
void shouldReturn401UnauthorizedWhenSessionIsNull(WireMockRuntimeInfo wm) throws Exception {
|
||||
mockMvc.perform(post(SYNC_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(syncRequestBody(wm))).andExpect(status().isUnauthorized());
|
||||
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "%s",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER, MOCK_PASS);
|
||||
}
|
||||
|
||||
private String syncRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "%s",
|
||||
"username": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_TEST_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
+14
-41
@@ -1,19 +1,21 @@
|
||||
# Technical preparation for adding documentation
|
||||
**Vaessl: Initial code-server setup**
|
||||
|
||||
Before documenting all my research for the planning phase of the app there are several technical preparations to arrange:
|
||||
|
||||
* I will be coding in a [code-server](https://github.com/coder/code-server) docker instance so that I can develop and build my app in a central location. This way I'm not dependent on building the project on every machine I work from.
|
||||
* I will be coding and documenting in a [code-server](https://github.com/coder/code-server) docker instance so that I can develop and build my app in a central location. This way I'm not dependent on building the project on every machine I work from.
|
||||
* I will be using a locally hosted docker instance on my Proxmox server to deploy the code-server container.
|
||||
* After preparing the container I will connect it to my self hosted Git(ea) repository to ensure a git flow from the very beginning.
|
||||
* To ensure SSL which is recommended for code-server I will use a tunnel with my Pangolin instance and Cloudflare as DNS resolver. This is a temporary solution later it will be changed to a self signed cert with Caddy so that is not truly publicly available. If I need to access code-server remotely I will do it via Wireguard tunnel.
|
||||
* While I'm at it I will set up all the ports for backend and frontend
|
||||
|
||||
## Code-Server docker container deployment
|
||||
# Code-Server docker container deployment
|
||||
|
||||
I use Portainer to setup my docker-compose yaml:
|
||||
|
||||
```
|
||||
services:
|
||||
code-server:
|
||||
image: code-server-dev
|
||||
image: code-server-dev:latest #note this is a custom image generated later (docs Preparation folder)
|
||||
container_name: code-server
|
||||
environment:
|
||||
- PUID=1000
|
||||
@@ -23,56 +25,27 @@ services:
|
||||
# - HASHED_PASSWORD= #optional
|
||||
# - SUDO_PASSWORD=password #optional
|
||||
# - SUDO_PASSWORD_HASH= #optional
|
||||
# - PROXY_DOMAIN=code-server.my.domain #optional
|
||||
- PROXY_DOMAIN=code-server.my.domain #this is important to generate a proper forward address for ports 8080 and 5173
|
||||
- DEFAULT_WORKSPACE=/config/workspace #optional
|
||||
volumes:
|
||||
- /home/pi/docker/vscode:/config
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 8124:8080 # spring port
|
||||
- 5173:5173 # vite port
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Before deploying the container I configure a Dockerfile to install openjdk, maven, npm, nodejs and yarn and set the environment variables for Java to ensure the Java and React development. Since the code-server container doesn't come with root access for its default user abc out of the box every root action will be configured here:
|
||||
|
||||
```
|
||||
FROM lscr.io/linuxserver/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
openjdk-25-jdk maven \
|
||||
nodejs npm \
|
||||
&& npm install -g yarn \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||
ENV PATH="$JAVA_HOME/bin:${PATH}"
|
||||
|
||||
USER abc
|
||||
```
|
||||
|
||||
Build the custom image and name it code-server-dev which is referenced in the docker-compose image above:
|
||||
|
||||
```
|
||||
docker build -t code-server-dev .
|
||||
```
|
||||
|
||||
Generate ssh keys within the code server folder to connect to my Gitea instance. Navigate to the volume and execute:
|
||||
|
||||
```
|
||||
ssh-keygen -t ed25519
|
||||
```
|
||||
|
||||
Save the keys to the .ssh folder.
|
||||
|
||||
Now deploy the docker-compose and check all the configurations:
|
||||
Check if the keys are there:
|
||||
|
||||
```
|
||||
java --version
|
||||
mvn --version
|
||||
npm --version
|
||||
yarn --version
|
||||
nodejs --version
|
||||
ls -l /config/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
@@ -86,8 +59,8 @@ ssh -T -p 222 [email protected]
|
||||
Add the key and make the initial git commit in code-server:
|
||||
|
||||
```
|
||||
mkdir where-is-my-stuff
|
||||
cd where-is-my-stuff
|
||||
mkdir project-name-folder
|
||||
cd project-name-folder
|
||||
touch README.md
|
||||
git init
|
||||
git config --global init.defaultBranch main
|
||||
@@ -95,9 +68,9 @@ git branch -m main
|
||||
git checkout -b main
|
||||
git add .
|
||||
git commit -m "initial commit"git config --global user.email "email@email.com"
|
||||
git config --global user.name "kasun"
|
||||
git config --global user.name "user"
|
||||
git commit -m "initial commit"
|
||||
git remote add origin ssh://git@192.168.1.208:222/kasun/Where-is-my-stuff.git
|
||||
git remote add origin ssh://git@192.168.1.208:222/user/project-name-folder.git
|
||||
git push -u origin main
|
||||
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
**Vaessl: Product and technical vision**
|
||||
|
||||
# Product vision
|
||||
Vaessl is an AI-powered bridge designed to connect physical reality to digital management systems. It functions as an intelligent intermediary that performs image analysis and semantic search, enriching raw data before "serving" it to a primary application via REST API.
|
||||
|
||||
# Technical requirements & stack
|
||||
|
||||
## Frontend
|
||||
- Framework: Vite (React)
|
||||
|
||||
- Styling: Tailwind CSS + SCSS
|
||||
|
||||
- Core Task: Provide a streamlined interface for capturing or uploading photos (single or multi-item) and a dedicated UI for reviewing AI-generated metadata.
|
||||
|
||||
## Backend
|
||||
- Framework: Spring Boot
|
||||
|
||||
- AI Orchestration: Spring AI
|
||||
|
||||
- AI Gateway: LiteLLM (A proxy container that allows Vaessl to communicate with various LLM providers—such as Gemini, Claude, or local models—using a unified protocol).
|
||||
|
||||
- Core Task: Managing the lifecycle of an item from initial photo upload to final API export.
|
||||
|
||||
## Database
|
||||
- System: PostgreSQL + pgvector (initial iteration)
|
||||
|
||||
- Role: Acts as the vector store for intelligent search and the engine for the image processing
|
||||
|
||||
# Functionality
|
||||
## Intelligent search
|
||||
Vaessl does not rely on exact word matching. Because it stores data in a vector-capable database, users can search via Natural Language Prompts.
|
||||
|
||||
Example: Searching for "tool to tighten a bolt" will return a "Wrench," even if the word "bolt" or "tighten" isn't in the item's title.
|
||||
|
||||
Process: The search prompt is converted into a vector by the AI, and Postgres performs a "Cosine Similarity" check to find the closest matches in the staging or history tables.
|
||||
|
||||
## Image processing workflow (Start to Finish)
|
||||
The image processing is the heart of Vaessl's "bridge" functionality. It ensures data quality before it reaches your primary app.
|
||||
|
||||
- Ingestion: When a photo is uploaded, the raw image and its initial metadata are saved into a temporary staging table in Postgres.
|
||||
|
||||
- Analysis: Vaessl sends the image to the AI (via LiteLLM). The AI identifies the items and returns structured data (names, categories, coordinates).
|
||||
|
||||
- Expansion: The database creates individual records for every detected item, linking them to the original source photo. These records are marked with a STAGED status.
|
||||
|
||||
- Refinement: The user interacts with the UI to edit, delete, or confirm the AI's findings. At this point, Spring AI generates a vector embedding of the final description and stores it in the pgvector column for future searching.
|
||||
|
||||
- Execution: Once the user hits "Confirm," Vaessl maps the staged data to the target application's format (e.g., Homebox JSON) and pushes it via REST API.
|
||||
|
||||
- Cleanup: Upon successful export, the staged record is either archived or removed from the local database, keeping the bridge lean.
|
||||
|
||||
## Deployment
|
||||
Vaessl is deployed via Docker Compose for high portability.
|
||||
|
||||
- Container 1: Vite (Web UI)
|
||||
- Container 2: Spring Boot
|
||||
- Container 3: PostgreSQL + pgvector
|
||||
- Container 4: LiteLLM
|
||||
@@ -0,0 +1,45 @@
|
||||
**Vaessl: Project Goals**
|
||||
|
||||
# Primary Objective
|
||||
|
||||
The goal of Vaessl is to bridge the gap between unstructured physical data (images) and structured digital systems (Inventory APIs). This project serves as a technical deep-dive into the integration of Generative AI within a traditional Full-Stack Java/Spring Boot environment.
|
||||
|
||||
# Learning & Development Pillars
|
||||
|
||||
While the end product is a functional utility, the development process is specifically designed to master the following domains:
|
||||
|
||||
- Modern Spring Boot & Spring AI: Moving beyond basic CRUD to orchestrate complex AI workflows, utilizing the Spring AI ecosystem to handle prompts and structured outputs.
|
||||
|
||||
- Vector Databases & Retrieval: Gaining hands-on experience in using vector databases in combination with classical development stacks.
|
||||
|
||||
- System Architecture: Designing a "Bridge" architecture that is decoupled from the target application, allowing for a flexible, provider-agnostic middleware service.
|
||||
|
||||
- AI Gateway Implementation: Mastering LiteLLM to manage multiple LLM providers (Gemini, OpenAI, Local LLMs).
|
||||
|
||||
- Containerized Orchestration: Delivering a production-ready environment via Docker Compose that manages networked services (App, DB, Proxy) with a single command.
|
||||
|
||||
# Product Goals (Public Value)
|
||||
|
||||
Vaessl is intended to be a useful tool for the public, specifically those utilizing self-hosted inventory systems like Homebox.
|
||||
|
||||
- Intuitive Discovery: Replace rigid keyword searches with intent-based search (e.g., finding a "Soldering Iron" when the user searches for "fix electronics").
|
||||
|
||||
- Improve productivity: Reduce the time it takes to catalog physical items through AI-assisted batch processing.
|
||||
|
||||
# Short-Term Roadmap & Milestones
|
||||
|
||||
- Phase 1: Foundation (Current)
|
||||
* Deploy the core infrastructure (PostgreSQL + pgvector, LiteLLM, Spring Boot Skeleton, Vite frontend).
|
||||
* Establish the bridge between the Vaessl backend and a demo Homebox instance.
|
||||
|
||||
- Phase 2: The Processing Pipeline
|
||||
* Implement the image processing workflow: Upload $\rightarrow$ AI analysis $\rightarrow$ staging Table.
|
||||
* Develop the Vite interface for data refinement and verification.
|
||||
|
||||
- Phase 3: Semantic Search & Demo
|
||||
* Integrate Spring AI for embedding generation.
|
||||
* Launch a public-facing demo to showcase the full-stack solution.
|
||||
|
||||
# Long-Term Roadmap & Milestones
|
||||
|
||||
TBD
|
||||
@@ -0,0 +1,41 @@
|
||||
**Vaessl: code-server adjustments**
|
||||
|
||||
Before developing on code-server I configure a Dockerfile to install all packages needed for Spring Boot, Java and Vite.
|
||||
|
||||
I install openjdk 25, nodejs 24.x and yarn and set the environment variables for Java (and the /config/.local/bin folder that gets used for tools like Claude Code).
|
||||
|
||||
Since the linuxserver code-server image doesn't come with root access for its default user abc out of the box every privileged action will be baked in here:
|
||||
|
||||
```
|
||||
FROM lscr.io/linuxserver/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
curl ca-certificates openjdk-25-jdk \
|
||||
# Add nodejs for React/Vite
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
||||
&& apt install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Java Environment
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||
ENV PATH="/config/.local/bin:$JAVA_HOME/bin:${PATH}"
|
||||
|
||||
```
|
||||
|
||||
Build the custom image and name it something like code-server-dev:
|
||||
|
||||
```
|
||||
docker build -t code-server-dev .
|
||||
```
|
||||
|
||||
Make sure to add code-server-dev:latest to the image env in the docker-compose file.
|
||||
|
||||
Now deploy the docker-compose and check all the configurations:
|
||||
|
||||
```
|
||||
java --version
|
||||
npm --version
|
||||
nodejs --version
|
||||
```
|
||||
@@ -0,0 +1,312 @@
|
||||
**Vaessl: Spring Boot and database setup**
|
||||
|
||||
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
|
||||
|
||||
The dependencies are chosen to specifically work with Spring AI and PostgreSQL/pgvector. For the AI function OpenAI is chosen since it is known to work with LiteLLM. The PostgreSQL dependency makes sure to include PostGreSQL support for self hosted databases.
|
||||
|
||||
# Spring Initializr settings
|
||||
|
||||
**Build System: Gradle - Kotlin**
|
||||
|
||||
Gradel Kotlin is chosen to make mobile/Android app development easier.
|
||||
|
||||
**Spring Boot: 4.0.4**
|
||||
|
||||
**Packaging: Jar**
|
||||
|
||||
**Configuration: YAML**
|
||||
|
||||
**Java: 25**
|
||||
|
||||
**Dependencies**
|
||||
|
||||
- Lombok
|
||||
- Spring Boot DevTools
|
||||
- Spring Web
|
||||
- Spring Security
|
||||
- Spring Data JPA
|
||||
- OpenAI AI
|
||||
- PostgreSQL Driver
|
||||
- Validation
|
||||
|
||||
# Project Settings
|
||||
|
||||
PostGreSQL and OpenAI need an initial setup so that the local instance is able to start. I will comment out Spring Security since user management is an issue for a later iteration of the app.
|
||||
|
||||
The build.gradle.kts will look something like this:
|
||||
|
||||
```
|
||||
plugins {
|
||||
java
|
||||
id("org.springframework.boot") version "4.0.4"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
}
|
||||
|
||||
group = "com.vaessl"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
extra["springAiVersion"] = "2.0.0-M3"
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
// implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
To configure OpenAI (which I will use instead of LiteLLM initially since I have an OpenAI Api key and can get going quickly) and PostGreSQL I will create a .env.local file in the root dir, fill all credentials and add it to .gitignore
|
||||
|
||||
```
|
||||
DB_URL=jdbc:postgresql://192.168.1.208:5433/vaessl
|
||||
DB_TEST_URL=jdbc:postgresql://192.168.1.208:5434/vaessl_test
|
||||
DB_USERNAME=myusername
|
||||
DB_PASSWORD=mypw
|
||||
OPENAI_KEY=myapikey
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
PG_DRIVER_CLASS_NAME=org.postgresql.Driver
|
||||
```
|
||||
|
||||
The initial application.yaml in the resources folder will look like this:
|
||||
|
||||
```
|
||||
spring:
|
||||
application:
|
||||
name: vaessl
|
||||
config:
|
||||
import: "optional:file:.env.local[.properties]"
|
||||
datasource:
|
||||
url : ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: true
|
||||
|
||||
ai:
|
||||
openai:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
api-key: ${OPENAI_KEY}
|
||||
chat:
|
||||
options:
|
||||
model: gpt-4o-mini
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.boot.context.config: TRACE
|
||||
|
||||
```
|
||||
|
||||
The Docker Compose file for code-server will look something like this:
|
||||
|
||||
```
|
||||
---
|
||||
services:
|
||||
code-server:
|
||||
image: code-server-dev:latest
|
||||
container_name: code-server
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/Vienna
|
||||
# - PASSWORD= #optional
|
||||
- HASHED_PASSWORD=hashedpw
|
||||
# - SUDO_PASSWORD_HASH= #optional
|
||||
- PROXY_DOMAIN=code-server.your.website
|
||||
- DEFAULT_WORKSPACE=/config/workspace
|
||||
volumes:
|
||||
- /home/pi/docker/vscode:/config
|
||||
- /home/pi/docker/vscode/workspace:/workspace
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 8124:8080
|
||||
- 5173:5173
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an official ready-made pgvector docker image but if you already host a PostgreSQL database you need to add the extension yourself.
|
||||
|
||||
## Installing pgvector on a self-hosted PostgreSQL container
|
||||
|
||||
pgvector is a PostgreSQL extension that adds a `vector` data type. It does not create a new database — it adds a `vector_store` table to your existing database. The shared library (`vector.so`) must be installed on the PostgreSQL server itself.
|
||||
|
||||
**Do not install it manually inside a running container.** Manual installations do not survive container recreation (e.g. after a `docker compose up --force-recreate` or image update). Instead, bake it into a custom Docker image.
|
||||
|
||||
### Step 1: Create a custom Dockerfile for PostgreSQL
|
||||
|
||||
Create a `Dockerfile.postgres` next to your `docker-compose.yaml`:
|
||||
|
||||
```dockerfile
|
||||
FROM postgres:18.4
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates git build-essential postgresql-server-dev-18 \
|
||||
&& git clone --branch v0.8.3 https://github.com/pgvector/pgvector.git \
|
||||
&& cd pgvector && make && make install \
|
||||
&& cd .. && rm -rf pgvector \
|
||||
&& apt-get purge -y ca-certificates git build-essential postgresql-server-dev-18 \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- `FROM postgres:18.4` — this is still the standard postgres image, not the pgvector image
|
||||
- `ca-certificates` — required for git to verify GitHub's SSL certificate during build
|
||||
- `postgresql-server-dev-18` — provides the PostgreSQL header files needed to compile pgvector
|
||||
|
||||
### Step 2: Update docker-compose.yaml to use the custom image
|
||||
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
container_name: postgres
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.postgres
|
||||
network: host
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
networks:
|
||||
- pg_network
|
||||
volumes:
|
||||
- /home/pi/docker/postgresql:/var/lib/postgresql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
networks:
|
||||
pg_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Step 3: Build the image and recreate the container
|
||||
|
||||
Before recreating, back up your databases:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-name> bash
|
||||
su - postgres -c "pg_dumpall > /tmp/backup.sql"
|
||||
exit
|
||||
docker cp <container-name>:/tmp/backup.sql .
|
||||
```
|
||||
|
||||
Build the image. Use `--network=host` to ensure the build container can reach GitHub:
|
||||
|
||||
```bash
|
||||
docker build --network=host -t postgres-pgvector -f Dockerfile.postgres .
|
||||
```
|
||||
|
||||
Recreate the container
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
### Step 4: Enable the extensions in each database
|
||||
|
||||
Run this once per database that needs pgvector:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-name> psql -U <db-user> -d <db-name>
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
```
|
||||
|
||||
`uuid-ossp` is also required — Spring AI's `vector_store` table uses `uuid_generate_v4()` for its primary key.
|
||||
|
||||
### Step 5: Add pgvector config to application.yaml
|
||||
|
||||
Spring AI's pgvector auto-configuration requires these properties:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
ai:
|
||||
vectorstore:
|
||||
pgvector:
|
||||
dimensions: 1536 # must match your embedding model output size
|
||||
distance-type: COSINE_DISTANCE
|
||||
index-type: HNSW
|
||||
initialize-schema: true # auto-creates the vector_store table on startup
|
||||
```
|
||||
|
||||
`dimensions` depends on the embedding model:
|
||||
- `text-embedding-ada-002` / `text-embedding-3-small` → 1536
|
||||
- `text-embedding-3-large` → 3072
|
||||
|
||||
`initialize-schema: true` means Spring AI will create the `vector_store` table automatically on first startup — no manual SQL needed.
|
||||
|
||||
# Appendix: Additional config for developing in Code-Server
|
||||
|
||||
When using the code-server container there are additional config steps to mind:
|
||||
|
||||
- Assign the 8080 port to a different port if it is used by another docker container by adding a port variable in the docker-compose.yaml. I assigned it to 8124.
|
||||
|
||||
```
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 8124:8080
|
||||
```
|
||||
|
||||
- define a proxy domain for automatic Url generations when starting localhost
|
||||
|
||||
```
|
||||
environment:
|
||||
- PROXY_DOMAIN=code-server.your.website
|
||||
```
|
||||
|
||||
This makes sure port 8080 is reachable via https://8080.code-server.your.website as per code-server documentation for using subdomains: https://coder.com/docs/code-server/guide#using-a-subdomain
|
||||
|
||||
- make sure to add the subdomain in your proxy platform like Cloudflare Zero Trust Tunnel or Pangolin and point it to the local ip in my case http://192.168.208:8124
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
**Vaessl: Frontend setup**
|
||||
|
||||
Vite will act as development server for the React app. The necessary packages especially the correct NodeJs version are baked into the Docker image documented in [code-server adjustments](https://gitea.kasuns.website/kasun/Vaessl/src/branch/main/docs/02-Preparation/01-code-server-adjustments.md)
|
||||
|
||||
Port 5173 needs to be exposed in the code-server container via port variable.
|
||||
|
||||
To ensure proper localhost Url generation the following server parameters need to be added to the vite.config.ts.
|
||||
|
||||
```
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['5173.code-server.your.website'],
|
||||
},
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
Don't forget to add the host to your proxy service.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
**vaessl: Test strategy**
|
||||
|
||||
# Spring Boot Test Environment
|
||||
1. Environment Parity
|
||||
|
||||
Instead of testing against a mocked database or the development database, I created a mirrored test container. This allows the application to perform JPA operations against a real PostgreSQL instance that matches the production engine but uses a different port (5434) and database name (vaessl_test).
|
||||
|
||||
2. Profile-Driven Configuration
|
||||
|
||||
I utilized the Spring Profiles mechanism. By tagging the test class with @ActiveProfiles("test"), Spring Boot ignores the standard application.yaml for specific keys and prioritizes src/test/resources/application-test.yaml.
|
||||
|
||||
The configuration is moved out of the Java code and into YAML. This follows the "Separation of Concerns" principle.
|
||||
|
||||
The file is placed in src/test/resources (a sibling to src/test/java). This is the standard Gradle "SourceSet" layout, ensuring the build tool automatically packages these settings only during the test phase.
|
||||
|
||||
3. Gradle Test Lifecycle
|
||||
|
||||
I corrected the dependency graph in build.gradle.kts. While the Initializr provides "test slices" (like data-jpa-test), a full integration test requires the Spring Boot Starter Test core.
|
||||
|
||||
This starter provides the YAML parsing engine and the SpringBootContextLoader.
|
||||
|
||||
# Frontend Test environment
|
||||
|
||||
1. The Vitest Stack
|
||||
|
||||
- Vitest: providing a unified configuration for both the development server and the test runner.
|
||||
|
||||
- jsdom: Provides a lightweight browser environment in Node.js.
|
||||
|
||||
- React Testing Library: Ensures tests are written from the user's perspective (DOM-based) rather than implementation details.
|
||||
|
||||
2. Dynamic Environment Configuration
|
||||
|
||||
The vitest.config.ts is configured to be environment-aware. By using loadEnv, the test runner can access variables from .env files, allowing us to:
|
||||
|
||||
- Dynamically set the Server Port (51204).
|
||||
|
||||
- Handle Allowed Hosts (crucial for code-server or LXC environments where the public URL might change).
|
||||
|
||||
- Inject environment-specific keys into the test.env context.
|
||||
|
||||
3. TDD Workflow (Red-Green-Refactor)
|
||||
|
||||
The environment is optimized for a fast feedback loop.
|
||||
|
||||
- npm run test: Runs Vitest in "Watch Mode," re-triggering tests instantly upon file changes.
|
||||
|
||||
- npm run test:ui: Launches the Vitest UI, providing a visual graph of test suites and real-time failure reports.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
**vaessl: Setup LiteLLM**
|
||||
|
||||
# LiteLLM Docker Compose setup
|
||||
|
||||
I chose to use my existing Docker and PostgreSQL instance to spin up a LiteLLM container. I use Portainer stacks to write the docker-compose.yaml. Instead of creating a .env file I just wrote all environment variables into the yaml. The official docker-compose.yaml may vary.
|
||||
|
||||
Before deploying the container create a config.yaml and prometheus.yml in the root folder:
|
||||
|
||||
*config.yaml*
|
||||
```
|
||||
general_settings:
|
||||
master_key: sk-key # 🔑 your proxy admin key (must start with sk-)
|
||||
database_url: "postgresql://pgUser:[email protected]:5432/litellm"
|
||||
```
|
||||
|
||||
*prometheus.yml*
|
||||
|
||||
```
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "litellm"
|
||||
static_configs:
|
||||
- targets: ["litellm:4000"]
|
||||
```
|
||||
|
||||
*Portainer stack*
|
||||
```
|
||||
services:
|
||||
litellm:
|
||||
#build:
|
||||
# context: .
|
||||
# args:
|
||||
# target: runtime
|
||||
image: docker.litellm.ai/berriai/litellm:main-stable
|
||||
volumes:
|
||||
- /home/pi/docker/litellm/config.yaml:/app/config.yaml
|
||||
command:
|
||||
- "--config=/app/config.yaml"
|
||||
ports:
|
||||
- "4000:4000" # Map the container port to the host, change the host port if necessary
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://pgUser:[email protected]:5432/litellm"
|
||||
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
|
||||
OPENAI_API_KEY: "sk-key"
|
||||
OPENAI_BASE_URL: "https://api.openai.com/v1"
|
||||
LITELLM_MASTER_KEY: "sk-key"
|
||||
LITELLM_SALT_KEY: "sk-saltkey"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
#env_file:
|
||||
# - /home/pi/docker/litellm/.env
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
|
||||
interval: 30s # Perform health check every 30 seconds
|
||||
timeout: 10s # Health check command times out after 10 seconds
|
||||
retries: 3 # Retry up to 3 times if health check fails
|
||||
start_period: 40s # Wait 40 seconds after container start before beginning health checks
|
||||
networks:
|
||||
- postgres_pg_network
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- /home/pi/docker/litellm/prometheus_data:/prometheus
|
||||
- /home/pi/docker/litellm/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
- "9090:9090"
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
driver: local
|
||||
postgres_data:
|
||||
name: litellm_postgres_data # Named volume for Postgres data persistence
|
||||
|
||||
networks:
|
||||
postgres_pg_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
|
||||
|
||||
Models can be added in the UI under localhost:4000 and will be saved into the database. No need to define them in the config.yaml
|
||||
@@ -0,0 +1,57 @@
|
||||
**vaessl: Setup API client**
|
||||
|
||||
# Locally hosted API client
|
||||
|
||||
I chose [Hoppscotch ](https://docs.hoppscotch.io/) as API client since it is free, open-source, has a Docker support and can be locally hosted. Together with its browser extension it can be run in the browser supporting my goal of a flexible, browser-based development environment where I can securely access and interact with my entire stack remotely.
|
||||
|
||||
```
|
||||
services:
|
||||
hoppscotch:
|
||||
image: hoppscotch/hoppscotch
|
||||
container_name: hoppscotch
|
||||
restart: unless-stopped
|
||||
# ATTENTION: Run command and entrypoint for the first time and then comment it out
|
||||
#command: '-c "pnpx prisma migrate deploy"'
|
||||
#entrypoint: sh
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
# Prisma Config
|
||||
DATABASE_URL: postgresql://user:[email protected]:5432/hoppscotch
|
||||
# (Optional) By default, the AIO container (when in subpath access mode) exposes the endpoint on port 80. Use this setting to specify a different port if needed.
|
||||
HOPP_AIO_ALTERNATE_PORT: 80
|
||||
# Sensitive Data Encryption Key while storing in Database (32 character)
|
||||
DATA_ENCRYPTION_KEY: 'key'
|
||||
# Whitelisted origins for the Hoppscotch App.
|
||||
# This list controls which origins can interact with the app through cross-origin comms.
|
||||
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
|
||||
# - app://localhost_3200: Bundle server origin identifier
|
||||
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
|
||||
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
|
||||
# bundle names like `app://{bundle-name}/`
|
||||
WHITELISTED_ORIGINS: http://localhost:3170,http://localhost:3009,http://localhost:3101,app://localhost_3200,app://hoppscotch
|
||||
# Base URLs
|
||||
VITE_BASE_URL: http://localhost:3009
|
||||
VITE_SHORTCODE_BASE_URL: http://localhost:3009
|
||||
VITE_ADMIN_URL: http://localhost:3101
|
||||
# Backend URLs
|
||||
VITE_BACKEND_GQL_URL: http://localhost:3170/graphql
|
||||
VITE_BACKEND_WS_URL: wss://localhost:3170/graphql
|
||||
VITE_BACKEND_API_URL: http://localhost:3170/v1
|
||||
# Terms Of Service And Privacy Policy Links (Optional)
|
||||
VITE_APP_TOS_LINK: https://docs.hoppscotch.io/support/terms
|
||||
VITE_APP_PRIVACY_POLICY_LINK: https://docs.hoppscotch.io/support/privacy
|
||||
# Set to `true` for subpath based access
|
||||
ENABLE_SUBPATH_BASED_ACCESS: false
|
||||
ports:
|
||||
- '3170:3170'
|
||||
- '3101:3100'
|
||||
- '3009:3000'
|
||||
networks:
|
||||
- postgres_pg_network
|
||||
|
||||
networks:
|
||||
postgres_pg_network:
|
||||
external: true
|
||||
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
**vaessl: Claude Code feasability**
|
||||
|
||||
This is a showcase of how useful Claude Code can be for an app such as Vaessl. There were very little steps to do to achieve an entire feature. This was achieved with a standard paid plan over 2 days. I never hit the limits and I must have used around 300k tokens likely a little more. Not only was I able to setup Java classes and React components. Claude Code pretty much nailed the CSS for the app. After minor adjustments for styling, CORS configurations, solving SonarQube warnings and generating additional integration tests I was good to commit the changes. The following documentation was also generated via CC.
|
||||
|
||||
The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca8b227c8ba285ac2.
|
||||
|
||||
|
||||
# What the feature achieves
|
||||
|
||||
Users can connect Vaessl to external services (starting with Homebox) by entering their credentials in a UI. The backend authenticates against the remote service, persists the
|
||||
connection in the database, and issues an HTTP session cookie. The frontend dashboard reflects live connection status and lets users disconnect.
|
||||
|
||||
---
|
||||
## Backend
|
||||
|
||||
**build.gradle.kts**
|
||||
|
||||
Added spring-boot-starter-session-jdbc (and its test counterpart). This enables Spring Session to store HTTP sessions in the PostgreSQL database rather than in-memory, so
|
||||
sessions survive server restarts and work correctly in multi-instance deployments.
|
||||
|
||||
---
|
||||
**application.yaml**
|
||||
|
||||
Two additions:
|
||||
- spring.session.store-type: jdbc activates the JDBC session store added above.
|
||||
- server.servlet.context-path: /api prefixes all endpoints with /api, keeping the API cleanly separated from the frontend when served from the same host.
|
||||
- vaessl.frontend-url externalizes the frontend origin so CORS can be configured without hardcoding.
|
||||
|
||||
---
|
||||
**config/CorsConfig.java (new)**
|
||||
|
||||
Spring MVC CORS configuration. The frontend runs on a different port/domain than the backend, so without this every browser request would be blocked. Key detail:
|
||||
allowCredentials(true) is required for the session cookie to be sent cross-origin — this is why the allowed origins are explicit (wildcards are forbidden when credentials are
|
||||
allowed). The three origins cover local dev, a LAN IP, and a tunnelled code-server URL.
|
||||
|
||||
---
|
||||
**config/SessionConfig.java (new)**
|
||||
|
||||
Customises the session cookie that Spring Session issues. HttpOnly prevents JavaScript from reading it (XSS mitigation). SameSite=Lax allows the cookie to travel with
|
||||
cross-site navigations (needed for the separate frontend origin) while blocking CSRF from third-party sites. CookieMaxAge = Integer.MAX_VALUE keeps the cookie persistent in the
|
||||
browser across tabs/restarts; the actual session lifetime is controlled per-login in the controller.
|
||||
|
||||
---
|
||||
**META-INF/additional-spring-configuration-metadata.json (new)**
|
||||
|
||||
Registers the custom vaessl.frontend-url and spring.session.store-type properties with the IDE. This gives autocomplete and documentation hints in application.yaml — it has no
|
||||
runtime effect.
|
||||
|
||||
---
|
||||
**dto/LoginResult.java (new)**
|
||||
|
||||
An internal record (connectionId, expiresAt) used as the return type of ConnectionService.login(). It carries back the database ID of the saved connection and the token expiry
|
||||
so the controller can use both without coupling to the entity layer.
|
||||
|
||||
---
|
||||
**dto/AuthResponse.java (new)**
|
||||
|
||||
The JSON body returned to the client after a successful login. It intentionally contains only serviceType and expiresAt — no tokens, no internal IDs — because all sensitive
|
||||
state lives server-side in the session and the database.
|
||||
|
||||
---
|
||||
**dto/ConnectionStatusResponse.java (new)**
|
||||
|
||||
The JSON body returned by the status endpoint for each active connection. Includes enough for the UI to show: which service, the URL it's connected to, the logged-in username,
|
||||
when the token expires, and whether it is currently considered connected (expiry has not passed).
|
||||
|
||||
---
|
||||
**connection/ConnectionProvider.java (modified)**
|
||||
|
||||
The provider interface gained five new methods:
|
||||
- checkCredentials — validates the request before any network call is made.
|
||||
- findUniqueConnectionEntry — looks up an existing database row for this user+URL combination.
|
||||
- connectionToEntity / updateToRepository — split "create new row" from "refresh token on existing row", so re-logging in updates the token rather than creating a duplicate.
|
||||
- getTokenExpiry — lets each provider expose its token lifetime (defaults to null meaning no expiry check).
|
||||
|
||||
This keeps all provider-specific logic inside the provider, not scattered across the service.
|
||||
|
||||
---
|
||||
**connection/ConnectionService.java (modified)**
|
||||
|
||||
login() was refactored to use the new provider methods and now returns LoginResult instead of void. The upsert logic (find existing → update or create new) lives here. A new
|
||||
method getConnectionStatus() was added: it loads the entity by ID, asks the provider for its expiry, and returns the status DTO. The ID-based lookup is safe here because the
|
||||
controller already resolved the ID from the session.
|
||||
|
||||
---
|
||||
**connection/ConnectionController.java (modified)**
|
||||
|
||||
Three endpoints now exist:
|
||||
|
||||
- POST /login — calls ConnectionService.login(), stores the returned connectionId in the HTTP session under a key like HOMEBOX_CONNECTION_ID, sets the session timeout to match
|
||||
the token expiry (minimum 5 minutes), and responds with AuthResponse.
|
||||
- GET /connections/status — reads all *_CONNECTION_ID keys from the session and calls getConnectionStatus for each, returning a list. Returns an empty list if no session exists
|
||||
(not an error).
|
||||
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
||||
|
||||
---
|
||||
**connection/HomeboxConnectionProvider.java (modified)**
|
||||
|
||||
Implements the new interface methods:
|
||||
- checkCredentials validates that username and password are present before touching the network.
|
||||
- findUniqueConnectionEntry queries the repository by appUrl + username.
|
||||
- connectionToEntity delegates to HomeboxEntity.from(...).
|
||||
- updateToRepository pattern-matches on HomeboxEntity and refreshes token, expiresAt, and attachmentToken.
|
||||
- getTokenExpiry returns the stored expiresAt from the HomeboxEntity.
|
||||
|
||||
---
|
||||
**connection/Endpoint.java (modified)**
|
||||
|
||||
Added CONNECTION_STATUS("/connections/status") to the enum, alongside the existing constants, so the integration test can reference the status endpoint without a magic string.
|
||||
|
||||
---
|
||||
##Frontend
|
||||
|
||||
types/connection.ts (new)
|
||||
|
||||
TypeScript interfaces that mirror the backend DTOs exactly: LoginRequest, AuthResponse, ConnectionStatus. This is the single source of truth for shapes shared across the
|
||||
frontend — the API layer and UI components both import from here.
|
||||
|
||||
---
|
||||
**api/client.ts (new)**
|
||||
|
||||
A thin apiFetch<T> wrapper around the native fetch API. Two important behaviours:
|
||||
- credentials: 'include' — sends the session cookie on every request (required for cross-origin session auth).
|
||||
- Error normalisation — on non-2xx responses it tries to extract a detail or title field from the JSON body (standard Spring error format) and throws it as an Error with a
|
||||
human-readable message.
|
||||
- 204 No Content is returned as undefined cast to T, avoiding a JSON parse error on empty bodies (used by the DELETE endpoint).
|
||||
|
||||
---
|
||||
**api/connections.ts (new)**
|
||||
|
||||
Three typed API call functions — login, getStatuses, logout — each wrapping the corresponding endpoint via apiFetch. The component layer calls these directly; they are not
|
||||
aware of the session cookie mechanism.
|
||||
|
||||
---
|
||||
**components/Dashboard.tsx (new)**
|
||||
|
||||
The top-level page component. It:
|
||||
- Defines the static SERVICES registry (currently just Homebox) — adding a new service means adding one entry here.
|
||||
- Fetches connection statuses on mount via useEffect.
|
||||
- Manages which connect-modal is open via openModal state.
|
||||
- Passes connect/disconnect callbacks down to ServiceCard and on success calls refresh() to re-fetch statuses.
|
||||
|
||||
---
|
||||
**components/ServiceCard.tsx (new)**
|
||||
|
||||
A presentational card for a single service. Displays the service icon, name, a coloured connected/disconnected badge, the logged-in username, and token expiry date. Shows a
|
||||
"Connect" or "Disconnect" button depending on state. It receives everything as props — no API calls inside.
|
||||
|
||||
---
|
||||
**components/ConnectModal.tsx (new)**
|
||||
|
||||
A modal dialog with a login form (App URL, username, password). On submit it calls login() from the API layer and calls onSuccess if it succeeds, or renders the error message
|
||||
if it fails. Auto-focuses the first field on open and closes on Escape key — both accessibility practices. Clicking the overlay also closes it.
|
||||
|
||||
---
|
||||
**App.tsx (modified)**
|
||||
|
||||
Replaced the previous placeholder content with a single <Dashboard /> render. The app entry point is now minimal by design — all layout and state live inside Dashboard.
|
||||
|
||||
---
|
||||
## Summary flow
|
||||
|
||||
User clicks "Connect" → ConnectModal form submitted
|
||||
→ POST /api/login (with credentials)
|
||||
→ backend authenticates against Homebox
|
||||
→ saves/updates connection row in DB
|
||||
→ stores connection ID in HTTP session (JDBC-backed)
|
||||
→ returns AuthResponse with expiry
|
||||
→ Dashboard calls getStatuses()
|
||||
→ GET /api/connections/status (session cookie sent)
|
||||
→ backend reads session, looks up DB rows, returns status list
|
||||
→ ServiceCard shows "Connected" with username + expiry
|
||||
@@ -0,0 +1,177 @@
|
||||
**vaessl: Login Architecture**
|
||||
|
||||
# Backend
|
||||
|
||||
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance are used to keep refactorings to a minimum. The first app to bridge is Homebox, which uses a classic username/password and Bearer token login process. The second hypothetical app, WikiJS, uses a user-generated API key. The abstractions cater to both authentication methods.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The server context path is `/api`. The three endpoints that drive the connection flow:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/login` | Authenticate a service; stores connection ID in session |
|
||||
| `GET` | `/api/connections/status` | List connected services for the current session |
|
||||
| `DELETE` | `/api/connections/{serviceType}` | Remove a service from the session; invalidates the session if no connections remain |
|
||||
|
||||
## Session Management
|
||||
|
||||
Authentication state is held server-side in a JDBC-backed Spring Session (stored in the same PostgreSQL database). After a successful login the controller stores `{serviceType}_CONNECTION_ID` in the `HttpSession`. The session cookie is `HttpOnly`, `SameSite=Lax`, and set to max age.
|
||||
|
||||
`CorsConfig` allows credentials from the two configured origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`). `allowCredentials(true)` is required for the session cookie to travel cross-origin.
|
||||
|
||||
## Single Table Inheritance
|
||||
|
||||
Database entities use Single Table Inheritance — one `connections` table with a `service_type` discriminator column and all app-specific nullable columns. A unique constraint on `(appUrl, username)` prevents duplicate entries per user per instance.
|
||||
|
||||
***ConnectionEntity.java***
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(name = "service_type")
|
||||
public abstract class ConnectionEntity {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String appUrl;
|
||||
private String username;
|
||||
}
|
||||
```
|
||||
|
||||
***HomeboxEntity.java***
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@DiscriminatorValue("HOMEBOX")
|
||||
public class HomeboxEntity extends ConnectionEntity {
|
||||
|
||||
private String token;
|
||||
private String attachmentToken;
|
||||
private Instant expiresAt;
|
||||
|
||||
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
|
||||
HomeboxEntity he = new HomeboxEntity();
|
||||
he.setAppUrl(request.appUrl());
|
||||
he.setUsername(request.username());
|
||||
he.setToken(response.token());
|
||||
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
|
||||
he.setExpiresAt(response.expiresAt());
|
||||
return he;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The Provider Pattern (Logic Layer)
|
||||
|
||||
Each integrated app implements `ConnectionProvider`. This separates how an app authenticates (the specific) from what the system does with that result (the general).
|
||||
|
||||
***ConnectionProvider.java***
|
||||
|
||||
```java
|
||||
public interface ConnectionProvider {
|
||||
|
||||
String getServiceType();
|
||||
|
||||
void checkCredentials(ConnectionRequest request);
|
||||
|
||||
ConnectionResponse authenticate(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
|
||||
|
||||
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
|
||||
|
||||
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
|
||||
|
||||
default Instant getTokenExpiry(ConnectionEntity entity) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
|
||||
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
|
||||
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
|
||||
|
||||
***HomeboxConnectionProvider.java***
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
@Override
|
||||
public String getServiceType() { return "HOMEBOX"; }
|
||||
|
||||
@Override
|
||||
public void checkCredentials(ConnectionRequest request) {
|
||||
// throws EmptyCredentialsException if username or password is null
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||
// POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
|
||||
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
||||
return HomeboxEntity.from(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
|
||||
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getTokenExpiry(ConnectionEntity entity) {
|
||||
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
***ConnectionService.java***
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
// auto-discovers all ConnectionProvider beans into a serviceType → provider map
|
||||
|
||||
public LoginResult login(ConnectionRequest request) {
|
||||
provider.checkCredentials(request);
|
||||
ConnectionResponse response = provider.authenticate(request);
|
||||
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
|
||||
// insert or update, then return LoginResult(connectionId, expiresAt)
|
||||
}
|
||||
|
||||
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
|
||||
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## DTOs
|
||||
|
||||
### Request / Internal
|
||||
|
||||
- **`ConnectionRequest`** — fields: `appUrl`, `serviceType`, `username`, `password`, `apiKey`, `stayLoggedIn`. Typed fields (not a credentials map) let providers validate exactly what they need via `checkCredentials()`. `apiKey` covers future API-key-only providers (e.g. WikiJS).
|
||||
|
||||
- **`ConnectionResponse`** — internal DTO between provider and service: `token`, `expiresAt`, `Map<String, Object> extraResponseData`. The `getExtraVar(key)` helper safely extracts app-specific values (e.g. Homebox's `attachmentToken`).
|
||||
|
||||
### Response (returned to frontend)
|
||||
|
||||
- **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
|
||||
|
||||
- **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
|
||||
|
||||
- **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
|
||||
|
||||
## Supported Service Types
|
||||
|
||||
Registered in the `ServiceType` enum. Currently: `HOMEBOX`. Adding a new service means implementing `ConnectionProvider` and adding the discriminator value — no changes to `ConnectionService` or `ConnectionController` required.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Git Conventions
|
||||
|
||||
## Branch Naming
|
||||
|
||||
```
|
||||
<type>/<short-kebab-case-description>
|
||||
```
|
||||
|
||||
- All lowercase, hyphens only — no camelCase or Title-Case
|
||||
- Keep the description short (3–5 words); the branch name is not the place for detail
|
||||
- If using a ticket tracker, include the ID: `feature/VAE-123-ai-search`
|
||||
|
||||
| Type | Use for |
|
||||
|---|---|
|
||||
| `feature/` | New functionality |
|
||||
| `fix/` | Bug fixes |
|
||||
| `refactor/` | Code changes with no behavior change |
|
||||
| `chore/` | Tooling, deps, config, cleanup |
|
||||
| `docs/` | Documentation only |
|
||||
|
||||
**Example:** `feature/ai-search`
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <imperative, present-tense summary>
|
||||
|
||||
<optional body — explain WHY, not WHAT>
|
||||
```
|
||||
|
||||
- **Type** — same list as branch types above, plus `test`, `style`
|
||||
- **Scope** — the module/package touched (e.g. `search`, `sync`, `connection`, `vector`, `frontend`)
|
||||
- **Summary** — imperative mood ("add", not "added" or "adds"); no period at the end
|
||||
- **Body** — only when the reasoning isn't obvious from the diff (a constraint, a bug workaround, a decision). Skip it for simple/self-explanatory changes.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(search): route SearchRequest.aiSearch to AI provider map
|
||||
fix(sync): prune stale vectors only after full page loop completes
|
||||
style: reformat
|
||||
refactor(vector): nest extraData by section for clearer embedding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ticket Titles
|
||||
|
||||
Frame as the outcome, not a task log:
|
||||
|
||||
```
|
||||
<Type>: <what changes for the user/system>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `Feature: Add AI-powered search to Homebox connector`
|
||||
- `Bug: /api/sync not triggered on login`
|
||||
- `Chore: Consolidate SearchService provider maps`
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vite
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vaessl ~ AI Bridge</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4571
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,10 @@
|
||||
|
||||
import { Dashboard } from './components/connections/Dashboard'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Dashboard/>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,21 @@
|
||||
const BASE = import.meta.env.VITE_API_URL ?? '/api'
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `Request failed (${res.status})`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.detail) message = body.detail
|
||||
else if (body?.title) message = body.title
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.status === 204 ? (undefined as T) : res.json()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiFetch } from "./client";
|
||||
import type {
|
||||
AuthResponse,
|
||||
ConnectionStatus,
|
||||
LoginRequest,
|
||||
ServiceType,
|
||||
} from "../types/connection";
|
||||
|
||||
export const login = (req: LoginRequest) =>
|
||||
apiFetch<AuthResponse>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
export const getStatuses = () =>
|
||||
apiFetch<ConnectionStatus[]>("/connections/status");
|
||||
|
||||
export const logout = (serviceType: ServiceType) =>
|
||||
apiFetch<void>(`/connections/${serviceType}`, { method: "DELETE" });
|
||||
@@ -0,0 +1,16 @@
|
||||
import { apiFetch } from "./client";
|
||||
import type {
|
||||
PagedSearchResponse,
|
||||
SearchRequest,
|
||||
ServiceItem,
|
||||
SyncRequest,
|
||||
} from "../types/search";
|
||||
|
||||
export const search = (req: SearchRequest) =>
|
||||
apiFetch<PagedSearchResponse<ServiceItem>>("/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
export const syncVectorData = (req: SyncRequest) =>
|
||||
apiFetch<Object>("/sync", { method: "POST", body: JSON.stringify(req) });
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import { login } from '../../api/connections'
|
||||
import type { LoginRequest, ServiceType } from '../../types/connection'
|
||||
import { uiText } from '../../text/uiText'
|
||||
import '../ui/Modal.scss'
|
||||
|
||||
interface Props {
|
||||
serviceType: ServiceType
|
||||
label: string
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonly<Props>) {
|
||||
const [appUrl, setAppUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
dialog.showModal()
|
||||
firstInputRef.current?.focus()
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('cancel', handleCancel)
|
||||
return () => dialog.removeEventListener('cancel', handleCancel)
|
||||
}, [onClose])
|
||||
|
||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const req: LoginRequest = { appUrl, serviceType, username, password, stayLoggedIn: true }
|
||||
await login(req)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : uiText.connections.connectModal.loginFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={dialogRef}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title" id="modal-title">{uiText.connections.connectModal.title(label)}</h2>
|
||||
<button className="modal__close" onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
|
||||
</div>
|
||||
<form className="modal__form" onSubmit={handleSubmit}>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="appUrl">{uiText.connections.connectModal.appUrlLabel}</label>
|
||||
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
|
||||
placeholder={uiText.connections.connectModal.appUrlPlaceholder}
|
||||
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
|
||||
</div>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="username">{uiText.connections.connectModal.usernameLabel}</label>
|
||||
<input id="username" className="modal__input" type="text"
|
||||
autoComplete="username"
|
||||
value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="password">{uiText.connections.connectModal.passwordLabel}</label>
|
||||
<input id="password" className="modal__input" type="password"
|
||||
autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
{error && <p className="modal__error">{error}</p>}
|
||||
<button className="modal__submit" type="submit" disabled={loading}>
|
||||
{loading ? uiText.connections.connectModal.connectingButton : uiText.connections.connectModal.connectButton}
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.dashboard {
|
||||
padding: 40px 40px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.5px;
|
||||
margin: 0 0 4px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
&__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { getStatuses, logout } from "../../api/connections"
|
||||
import { ServiceType, type ConnectionStatus } from "../../types/connection"
|
||||
import { ServiceCard } from "./ServiceCard"
|
||||
import { ConnectModal } from "./ConnectModal"
|
||||
import "./Dashboard.scss"
|
||||
import { SearchModal } from "../search/SearchModal"
|
||||
import { uiText } from "../../text/uiText"
|
||||
|
||||
const SERVICES = [
|
||||
{ serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' },
|
||||
] as const
|
||||
|
||||
export function Dashboard() {
|
||||
const [statuses, setStatuses] = useState<ConnectionStatus[]>([])
|
||||
const [openConnectionModal, setOpenConnectionModal] = useState<string | null>(null)
|
||||
const [openSearchModal, setOpenSearchModal] = useState<string | null>(null)
|
||||
|
||||
const refresh = () => {
|
||||
getStatuses().then(setStatuses).catch(() => { })
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const handleDisconnect = (serviceType: ServiceType) => {
|
||||
logout(serviceType).then(refresh).catch(() => { })
|
||||
}
|
||||
|
||||
const activeConnectionModal = SERVICES.find(s => s.serviceType === openConnectionModal)
|
||||
const activeSearchModal = SERVICES.find(s => s.serviceType === openSearchModal)
|
||||
const activeSearchStatus = statuses.find(s => s.serviceType === openSearchModal)
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard__header">
|
||||
<h1 className="dashboard__title">{uiText.dashboard.title}</h1>
|
||||
</div>
|
||||
|
||||
<p className="dashboard__section-label">{uiText.dashboard.servicesLabel}</p>
|
||||
<div className="dashboard__cards">
|
||||
{SERVICES.map(({ serviceType, label, icon }) => (
|
||||
<ServiceCard
|
||||
key={serviceType}
|
||||
serviceType={serviceType}
|
||||
label={label}
|
||||
icon={icon}
|
||||
status={statuses.find(s => s.serviceType === serviceType) ?? null}
|
||||
onConnect={() => setOpenConnectionModal(serviceType)}
|
||||
onDisconnect={() => handleDisconnect(serviceType)}
|
||||
onSearch={() => setOpenSearchModal(serviceType)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeConnectionModal && (
|
||||
<ConnectModal
|
||||
serviceType={activeConnectionModal.serviceType}
|
||||
label={activeConnectionModal.label}
|
||||
onClose={() => setOpenConnectionModal(null)}
|
||||
onSuccess={() => { setOpenConnectionModal(null); refresh() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSearchModal && (
|
||||
<SearchModal
|
||||
serviceType={activeSearchModal.serviceType}
|
||||
label={activeSearchModal.label}
|
||||
appUrl={activeSearchStatus?.appUrl ?? ''}
|
||||
username={activeSearchStatus?.username ?? ''}
|
||||
onClose={() => setOpenSearchModal(null) }
|
||||
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.service-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-family: var(--heading);
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
margin: 0 0 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&--connected {
|
||||
color: #16a34a;
|
||||
|
||||
&::before {
|
||||
background: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
&--disconnected {
|
||||
color: var(--text);
|
||||
|
||||
&::before {
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ConnectionStatus, ServiceType } from '../../types/connection'
|
||||
import { ActionButton } from '../ui/ActionButton'
|
||||
import { uiText } from '../../text/uiText'
|
||||
import './ServiceCard.scss'
|
||||
|
||||
interface Props {
|
||||
serviceType: ServiceType
|
||||
label: string
|
||||
icon: string
|
||||
status: ConnectionStatus | null
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onSearch: () => void
|
||||
}
|
||||
|
||||
export function ServiceCard({ serviceType: _serviceType, label, icon, status, onConnect, onDisconnect, onSearch }: Readonly<Props>) {
|
||||
const connected = status?.connected ?? false
|
||||
|
||||
const formatExpiry = (iso: string | null) => {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString(undefined, { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-card">
|
||||
<div className="service-card__info">
|
||||
<div className="service-card__icon">{icon}</div>
|
||||
<div>
|
||||
<p className="service-card__name">{label}</p>
|
||||
<p className="service-card__meta">
|
||||
<span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}>
|
||||
{connected ? uiText.connections.serviceCard.connected : uiText.connections.serviceCard.notConnected}
|
||||
</span>
|
||||
{connected && status?.username && <span>{status.username}</span>}
|
||||
{connected && status?.expiresAt && (
|
||||
<span>{uiText.connections.serviceCard.expiresPrefix(formatExpiry(status.expiresAt) ?? '')}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-card__actions">
|
||||
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
|
||||
{connected ? uiText.connections.serviceCard.disconnectButton : uiText.connections.serviceCard.connectButton}
|
||||
</ActionButton>
|
||||
{connected && (<ActionButton variant='search' onClick={onSearch}>{uiText.connections.serviceCard.searchButton}</ActionButton>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import '../ui/Modal.scss'
|
||||
import { type PagedSearchResponse, type ServiceItem, type SearchRequest, type SyncRequest } from '../../types/search'
|
||||
import { syncVectorData, search } from '../../api/searches'
|
||||
import type { ServiceType } from '../../types/connection'
|
||||
import { uiText } from '../../text/uiText'
|
||||
|
||||
interface Props {
|
||||
serviceType: ServiceType
|
||||
label: string
|
||||
appUrl: string
|
||||
username: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly<Props>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
//TODO: implement aiSearch
|
||||
const [aiSearch, setAiSearch] = useState(false)
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
dialog.showModal()
|
||||
firstInputRef.current?.focus()
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('cancel', handleCancel)
|
||||
return () => dialog.removeEventListener('cancel', handleCancel)
|
||||
}, [onClose])
|
||||
|
||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setSearchError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch}
|
||||
const res = await search(req)
|
||||
setResults(res)
|
||||
} catch (err) {
|
||||
setSearchError(err instanceof Error ? err.message : uiText.search.searchFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const req: SyncRequest = { appUrl, serviceType, username}
|
||||
await syncVectorData(req)
|
||||
} catch (err) {
|
||||
setSyncError(err instanceof Error ? err.message: uiText.search.syncFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className='modal' ref={dialogRef}>
|
||||
<div className='modal__header'>
|
||||
<h2 className='modal__title' id='modal-title'>{uiText.search.title(label)}</h2>
|
||||
<button className='modal__close' onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
|
||||
</div>
|
||||
<form className='modal__form' onSubmit={handleSubmit}>
|
||||
<div className='modal__field'>
|
||||
<input id='search' className='modal__input'
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{searchError && <p className='modal__error'>{searchError}</p>}
|
||||
{syncError && <p className='modal__error'>{syncError}</p>}
|
||||
<div className='modal__actions'>
|
||||
<button className='modal__other' title={uiText.search.syncTooltip(label)} type='button' disabled={loading} onClick={handleSync}>{uiText.search.refreshDataButton}</button>
|
||||
<button className='modal__submit' type='submit' disabled={loading}>{uiText.search.searchButton}</button>
|
||||
</div>
|
||||
</form>
|
||||
{results && (
|
||||
<div className='modal__results'>
|
||||
<p className='modal__results-count'>{results.totalElements}</p>
|
||||
{results.content.map((item) => (<div key={item.id} className='modal__result-item'>
|
||||
<p className='"modal__result-title'>{item.title}</p>
|
||||
{item.description && <p className='modal__result-desc'>{item.description}</p>}
|
||||
|
||||
</div>))}
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.action-btn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--connect,
|
||||
&--search {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--disconnect {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
|
||||
&:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import './ActionButton.scss'
|
||||
|
||||
type Variant = 'connect' | 'disconnect' | 'search'
|
||||
|
||||
interface Props {
|
||||
variant: Variant
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ActionButton({ variant, onClick, children }: Readonly<Props>) {
|
||||
return (
|
||||
<button className={`action-btn action-btn--${variant}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user