Compare commits

...
Author SHA1 Message Date
kasun b1a8985b29 added RequiredArgsConstructor annotation 2026-08-11 01:17:40 +02:00
kasun 9073c52b5c refactor: clean up build.gradle 2026-07-21 16:01:12 +02:00
kasun 6981734bda Merge branch 'main' into feature/Implement-AI-search-function 2026-07-21 01:36:43 +02:00
kasun 5f4f417edc docs: add git and ticket conventions
SonarQube Analysis / Build and Analyze (push) Successful in 2m44s
2026-07-21 01:36:24 +02:00
kasun 3736cc73ae Nest extraData by section for clearer embedding content
Homebox's location fields were flattened into single-purpose keys
(locationName/locationDescription), which gave EmbeddingService no
way to group related fields when rendering vector-store content.
extraData is now Map<String, Map<String, Object>>, letting
HomeboxItemClient express "location" as its own section; buildExtraData
renders each section as a header with indented fields instead of a flat
"Extradata: key: value" line.
2026-07-21 01:04:19 +02:00
kasun 29027d4515 formatting 2026-07-21 00:57:48 +02:00
kasun 444ce804ed introduced uiText.ts 2026-07-20 16:44:21 +02:00
kasunandClaude Sonnet 5 f2ab2b31ed Add manual sync trigger to SearchModal
Wires the existing /sync endpoint to a "Refresh Data" button so
Homebox data can be re-vectorized before searching, without waiting
for it to be triggered automatically on login.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-20 16:32:25 +02:00
kasun bb7bef18d9 format 2026-07-20 16:31:01 +02:00
kasun 1047e839f5 format 2026-07-20 14:15:01 +02:00
kasun 87108a9ebd added SyncControllerTests 2026-07-19 16:07:47 +02:00
kasun 1e0a5c0af1 removoed unused variables 2026-07-17 00:36:53 +02:00
22 changed files with 524 additions and 220 deletions
+53 -56
View File
@@ -1,83 +1,80 @@
val wiremockVersion = "3.12.0" val wiremockVersion = "3.12.0"
val postgresqlVersion = "42.7.11" val postgresqlVersion = "42.7.11"
val springAiVersion by extra("2.0.0")
plugins { plugins {
java java
jacoco jacoco
id("org.springframework.boot") version "4.1.0" id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
id("org.sonarqube") version "7.3.0.8198" id("org.sonarqube") version "7.3.0.8198"
} }
group = "com.vaessl" group = "com.vaessl"
version = "0.0.1-SNAPSHOT" version = "0.0.1-SNAPSHOT"
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(25) languageVersion = JavaLanguageVersion.of(25)
} }
} }
sonar { sonar {
properties { properties {
property("sonar.projectKey", "Vaessl") property("sonar.projectKey", "Vaessl")
property("sonar.projectName", "Vaessl") property("sonar.projectName", "Vaessl")
property("sonar.coverage.jacoco.xmlReportPaths", property("sonar.coverage.jacoco.xmlReportPaths",
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml") "${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
} }
} }
configurations { configurations {
compileOnly { compileOnly {
extendsFrom(configurations.annotationProcessor.get()) extendsFrom(configurations.annotationProcessor.get())
} }
} }
repositories { repositories {
mavenCentral() mavenCentral()
}
extra["springAiVersion"] = "2.0.0"
dependencies {
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-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")
implementation("org.postgresql:postgresql:$postgresqlVersion")
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
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")
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
dependencyManagement { dependencyManagement {
imports { imports {
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") 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> { tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters") options.compilerArgs.add("-parameters")
} }
tasks.withType<Test> { tasks.withType<Test> {
@@ -97,4 +94,4 @@ tasks.jacocoTestReport {
reports { reports {
xml.required = true xml.required = true
} }
} }
@@ -1,6 +1,6 @@
package com.vaessl.app.homebox; package com.vaessl.app.homebox;
import java.util.HashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -43,7 +43,8 @@ public class HomeboxItemClient {
* @param query optional keyword filter; {@code null} returns all items for the page * @param query optional keyword filter; {@code null} returns all items for the page
* @param pageable page number and size to request * @param pageable page number and size to request
* @return the mapped page of items along with the resolved connection ID * @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.ConnectionNotFoundException if no matching connection is
* stored
* @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body * @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body
*/ */
public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query, public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query,
@@ -72,12 +73,18 @@ public class HomeboxItemClient {
String description = i.description(); String description = i.description();
HomeboxParent parent = i.parent(); HomeboxParent parent = i.parent();
Map<String, Object> extraData = new HashMap<>(); Map<String, Map<String, Object>> extraData = new LinkedHashMap<>();
Map<String, Object> locationData = new LinkedHashMap<>();
if (parent.name() != null && !parent.name().isBlank()) { if (parent.name() != null && !parent.name().isBlank()) {
extraData.put("locationName", parent.name()); locationData.put("name", parent.name());
} }
if (parent.description() != null && !parent.description().isBlank()) { if (parent.description() != null && !parent.description().isBlank()) {
extraData.put("locationDescription", parent.description()); locationData.put("description", parent.description());
}
if (locationData != null && !locationData.isEmpty()) {
extraData.put("location", locationData);
} }
return new ServiceItem(id, title, description, extraData); return new ServiceItem(id, title, description, extraData);
@@ -2,7 +2,8 @@ package com.vaessl.app.shared;
public enum Endpoint { public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS( HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/entities"), SEARCH("/search"); "/connections/status"), HOMEBOX_QUERY_ALL_ITEMS(
"/api/v1/entities"), SEARCH("/search"), SYNC("/sync");
private final String value; private final String value;
@@ -4,7 +4,7 @@ import java.util.Map;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
public record ServiceItem(String id, @NotNull String title, String description, public record ServiceItem(String id, @NotNull String title, String description,
Map<String, Object> extraData) { Map<String, Map<String, Object>> extraData) {
public String getExtra(String key) { public String getExtra(String key) {
if (extraData == null) { if (extraData == null) {
@@ -14,10 +14,10 @@ import com.vaessl.app.vector.EmbeddingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Pages through the full Homebox catalog and re-indexes it into the vector store. * Pages through the full Homebox catalog and re-indexes it into the vector store. Item IDs are
* Item IDs are collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries} * collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries} is called
* is called once at the end, rather than after each page: deleting per page would treat every * once at the end, rather than after each page: deleting per page would treat every item outside
* item outside the current page as stale, wiping out entries from pages already synced. * the current page as stale, wiping out entries from pages already synced.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -4,29 +4,26 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.vaessl.app.shared.ServiceItem; import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType; import com.vaessl.app.shared.ServiceType;
import lombok.RequiredArgsConstructor;
/** /**
* Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer * Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer exist
* exist upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is * upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is a
* a singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator. * singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator.
*/ */
@Service @Service
@RequiredArgsConstructor
public class EmbeddingService { public class EmbeddingService {
private static final float THRESHOLD = 0.7f;
private static final int LIMIT = 2;
private final VectorStore vectorStore; private final VectorStore vectorStore;
public EmbeddingService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/** /**
* Embeds and upserts {@code items} into the vector store, appending each item's ID to * 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 * {@code currentItemIds} so the caller can later prune stale entries via
@@ -56,21 +53,30 @@ public class EmbeddingService {
return new Document(connectionId + ":" + item.id(), content, metadata); return new Document(connectionId + ":" + item.id(), content, metadata);
} }
private String buildExtraData(Map<String, Object> extraData) { private String buildExtraData(Map<String, Map<String, Object>> extraData) {
if (extraData == null) { if (extraData == null) {
return ""; return "";
} }
StringBuilder data = new StringBuilder();
for (Map.Entry<String, Object> entry : extraData.entrySet()) { 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()) { if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
continue; continue;
} }
if (!data.isEmpty()) {
data.append(" \n"); 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");
}
} }
data.append(entry.getKey()).append(": ").append(entry.getValue());
formattedData.append(entry.getKey()).append(": ").append("\n").append(data.toString());
} }
return data.toString(); return formattedData.toString();
} }
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType, private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
@@ -83,21 +89,22 @@ public class EmbeddingService {
} }
private String buildContent(ServiceItem item, String extraData) { private String buildContent(ServiceItem item, String extraData) {
StringBuilder content = new StringBuilder("Title: ").append(item.title()); StringBuilder content = new StringBuilder("title: ").append(item.title());
if (item.description() != null && !item.description().isEmpty()) { if (item.description() != null && !item.description().isEmpty()) {
content.append("\nDescription: ").append(item.description()); content.append("\ndescription: ").append(item.description());
} }
if (!extraData.isEmpty()) { if (!extraData.isEmpty()) {
content.append("\nExtradata: ").append(extraData);
content.append("\n").append(extraData);
} }
return content.toString(); return content.toString();
} }
/** /**
* Deletes every vector for {@code connectionId} whose item ID is not in * Deletes every vector for {@code connectionId} whose item ID is not in {@code currentItemIds},
* {@code currentItemIds}, then clears the accumulator. Must be called once, after every page * then clears the accumulator. Must be called once, after every page for this sync run has gone
* for this sync run has gone through {@link #vectorizeData}, not per page — otherwise items * through {@link #vectorizeData}, not per page — otherwise items from pages other than the most
* from pages other than the most recent one would look stale and get deleted too. * recent one would look stale and get deleted too.
* *
* @param connectionId the connection to prune stale vectors for * @param connectionId the connection to prune stale vectors for
* @param currentItemIds every item ID seen across the full sync run; cleared after this call * @param currentItemIds every item ID seen across the full sync run; cleared after this call
@@ -13,4 +13,43 @@ public final class Mockdata {
public static final String MOCK_ID = "item-1"; public static final String MOCK_ID = "item-1";
public static final String MOCK_TITLE = "title"; public static final String MOCK_TITLE = "title";
public static final String MOCK_DESCRIPTION = "desc"; 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"
}
]
}
""";
} }
@@ -2,7 +2,14 @@ package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.MOCK_PASS; import static com.vaessl.app.Mockdata.MOCK_PASS;
import static com.vaessl.app.Mockdata.MOCK_USER; import static com.vaessl.app.Mockdata.MOCK_USER;
import static com.vaessl.app.shared.Endpoint.*; 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.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -18,6 +25,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import jakarta.servlet.http.Cookie; 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 @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@WireMockTest @WireMockTest
@@ -32,47 +44,14 @@ class SearchControllerTest {
private static final String SEARCH_REQUEST = SEARCH.getValue(); private static final String SEARCH_REQUEST = SEARCH.getValue();
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """ /**
{ * Logs in against a stubbed Homebox instance, then performs a keyword search using the
"token": "fake-bearer-token", * resulting session cookie. Expects {@code 200 OK} with the mocked item mapped into the paged
"attachmentToken": "fake-attach-token", * response body.
"expiresAt": "2099-01-01T00:00:00Z" *
} * @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
"""; * at the stub server
*/
private 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"
}
]
}
""";
@Test @Test
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception { void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
@@ -93,9 +72,14 @@ class SearchControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398")) .andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
.andExpect(jsonPath("$.totalElements").value(1)) .andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].extraData.locationName").value("Server Schrank Ikea weiß")); .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 @Test
void shouldReturnUnauthorizedWhenNoSession() throws Exception { void shouldReturnUnauthorizedWhenNoSession() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content(""" mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
@@ -109,6 +93,10 @@ class SearchControllerTest {
""")).andExpect(status().isUnauthorized()); """)).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 @Test
void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception { void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON) mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON)
@@ -18,7 +18,7 @@ class SearchResponseTest {
@Test @Test
void shouldReturnNullWhenExtraDataKeyIsMissing() { void shouldReturnNullWhenExtraDataKeyIsMissing() {
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", Map.of("key", "value")));
assertThat(response.getExtra("missing")).isNull(); assertThat(response.getExtra("missing")).isNull();
} }
@@ -26,7 +26,8 @@ class SearchResponseTest {
@Test @Test
void shouldReturnExtraDataValue() { void shouldReturnExtraDataValue() {
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value")); 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.id()).isEqualTo(MOCK_ID);
assertThat(response.getExtra("key")).contains("value"); 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);
}
}
+61
View File
@@ -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 (35 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`
+13 -5
View File
@@ -1,11 +1,19 @@
import { apiFetch } from './client' import { apiFetch } from "./client";
import type { AuthResponse, ConnectionStatus, LoginRequest, ServiceType } from '../types/connection' import type {
AuthResponse,
ConnectionStatus,
LoginRequest,
ServiceType,
} from "../types/connection";
export const login = (req: LoginRequest) => export const login = (req: LoginRequest) =>
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) }) apiFetch<AuthResponse>("/login", {
method: "POST",
body: JSON.stringify(req),
});
export const getStatuses = () => export const getStatuses = () =>
apiFetch<ConnectionStatus[]>('/connections/status') apiFetch<ConnectionStatus[]>("/connections/status");
export const logout = (serviceType: ServiceType) => export const logout = (serviceType: ServiceType) =>
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' }) apiFetch<void>(`/connections/${serviceType}`, { method: "DELETE" });
+4
View File
@@ -3,6 +3,7 @@ import type {
PagedSearchResponse, PagedSearchResponse,
SearchRequest, SearchRequest,
ServiceItem, ServiceItem,
SyncRequest,
} from "../types/search"; } from "../types/search";
export const search = (req: SearchRequest) => export const search = (req: SearchRequest) =>
@@ -10,3 +11,6 @@ export const search = (req: SearchRequest) =>
method: "POST", method: "POST",
body: JSON.stringify(req), body: JSON.stringify(req),
}); });
export const syncVectorData = (req: SyncRequest) =>
apiFetch<Object>("/sync", { method: "POST", body: JSON.stringify(req) });
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import { login } from '../../api/connections' import { login } from '../../api/connections'
import type { LoginRequest, ServiceType } from '../../types/connection' import type { LoginRequest, ServiceType } from '../../types/connection'
import { uiText } from '../../text/uiText'
import '../ui/Modal.scss' import '../ui/Modal.scss'
interface Props { interface Props {
@@ -44,7 +45,7 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
await login(req) await login(req)
onSuccess() onSuccess()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed') setError(err instanceof Error ? err.message : uiText.connections.connectModal.loginFailed)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -53,31 +54,31 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
return ( return (
<dialog className="modal" ref={dialogRef}> <dialog className="modal" ref={dialogRef}>
<div className="modal__header"> <div className="modal__header">
<h2 className="modal__title" id="modal-title">Connect to {label}</h2> <h2 className="modal__title" id="modal-title">{uiText.connections.connectModal.title(label)}</h2>
<button className="modal__close" onClick={onClose} aria-label="Close">×</button> <button className="modal__close" onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
</div> </div>
<form className="modal__form" onSubmit={handleSubmit}> <form className="modal__form" onSubmit={handleSubmit}>
<div className="modal__field"> <div className="modal__field">
<label className="modal__label" htmlFor="appUrl">App URL</label> <label className="modal__label" htmlFor="appUrl">{uiText.connections.connectModal.appUrlLabel}</label>
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url" <input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
placeholder="https://homebox.example.com" placeholder={uiText.connections.connectModal.appUrlPlaceholder}
value={appUrl} onChange={e => setAppUrl(e.target.value)} required /> value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
</div> </div>
<div className="modal__field"> <div className="modal__field">
<label className="modal__label" htmlFor="username">Username</label> <label className="modal__label" htmlFor="username">{uiText.connections.connectModal.usernameLabel}</label>
<input id="username" className="modal__input" type="text" <input id="username" className="modal__input" type="text"
autoComplete="username" autoComplete="username"
value={username} onChange={e => setUsername(e.target.value)} required /> value={username} onChange={e => setUsername(e.target.value)} required />
</div> </div>
<div className="modal__field"> <div className="modal__field">
<label className="modal__label" htmlFor="password">Password</label> <label className="modal__label" htmlFor="password">{uiText.connections.connectModal.passwordLabel}</label>
<input id="password" className="modal__input" type="password" <input id="password" className="modal__input" type="password"
autoComplete="current-password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} required /> value={password} onChange={e => setPassword(e.target.value)} required />
</div> </div>
{error && <p className="modal__error">{error}</p>} {error && <p className="modal__error">{error}</p>}
<button className="modal__submit" type="submit" disabled={loading}> <button className="modal__submit" type="submit" disabled={loading}>
{loading ? 'Connecting…' : 'Connect'} {loading ? uiText.connections.connectModal.connectingButton : uiText.connections.connectModal.connectButton}
</button> </button>
</form> </form>
</dialog> </dialog>
@@ -5,6 +5,7 @@ import { ServiceCard } from "./ServiceCard"
import { ConnectModal } from "./ConnectModal" import { ConnectModal } from "./ConnectModal"
import "./Dashboard.scss" import "./Dashboard.scss"
import { SearchModal } from "../search/SearchModal" import { SearchModal } from "../search/SearchModal"
import { uiText } from "../../text/uiText"
const SERVICES = [ const SERVICES = [
{ serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' }, { serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' },
@@ -32,10 +33,10 @@ export function Dashboard() {
return ( return (
<div className="dashboard"> <div className="dashboard">
<div className="dashboard__header"> <div className="dashboard__header">
<h1 className="dashboard__title">Vaessl Dashboard</h1> <h1 className="dashboard__title">{uiText.dashboard.title}</h1>
</div> </div>
<p className="dashboard__section-label">Services</p> <p className="dashboard__section-label">{uiText.dashboard.servicesLabel}</p>
<div className="dashboard__cards"> <div className="dashboard__cards">
{SERVICES.map(({ serviceType, label, icon }) => ( {SERVICES.map(({ serviceType, label, icon }) => (
<ServiceCard <ServiceCard
@@ -32,7 +32,6 @@
color: var(--text-h); color: var(--text-h);
margin: 0 0 10px; margin: 0 0 10px;
text-align: left; text-align: left;
} }
&__meta { &__meta {
@@ -54,7 +53,7 @@
border-radius: 999px; border-radius: 999px;
&::before { &::before {
content: ''; content: "";
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
@@ -81,4 +80,4 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
} }
@@ -1,5 +1,6 @@
import type { ConnectionStatus, ServiceType } from '../../types/connection' import type { ConnectionStatus, ServiceType } from '../../types/connection'
import { ActionButton } from '../ui/ActionButton' import { ActionButton } from '../ui/ActionButton'
import { uiText } from '../../text/uiText'
import './ServiceCard.scss' import './ServiceCard.scss'
interface Props { interface Props {
@@ -29,20 +30,20 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
<p className="service-card__name">{label}</p> <p className="service-card__name">{label}</p>
<p className="service-card__meta"> <p className="service-card__meta">
<span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}> <span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Not connected'} {connected ? uiText.connections.serviceCard.connected : uiText.connections.serviceCard.notConnected}
</span> </span>
{connected && status?.username && <span>{status.username}</span>} {connected && status?.username && <span>{status.username}</span>}
{connected && status?.expiresAt && ( {connected && status?.expiresAt && (
<span>· expires {formatExpiry(status.expiresAt)}</span> <span>{uiText.connections.serviceCard.expiresPrefix(formatExpiry(status.expiresAt) ?? '')}</span>
)} )}
</p> </p>
</div> </div>
</div> </div>
<div className="service-card__actions"> <div className="service-card__actions">
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}> <ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
{connected ? 'Disconnect' : 'Connect'} {connected ? uiText.connections.serviceCard.disconnectButton : uiText.connections.serviceCard.connectButton}
</ActionButton> </ActionButton>
{connected && (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>)} {connected && (<ActionButton variant='search' onClick={onSearch}>{uiText.connections.serviceCard.searchButton}</ActionButton>)}
</div> </div>
</div> </div>
) )
+29 -14
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import '../ui/Modal.scss' import '../ui/Modal.scss'
import { type PagedSearchResponse, type ServiceItem, type SearchRequest } from '../../types/search' import { type PagedSearchResponse, type ServiceItem, type SearchRequest, type SyncRequest } from '../../types/search'
import { search } from '../../api/searches' import { syncVectorData, search } from '../../api/searches'
import type { ServiceType } from '../../types/connection' import type { ServiceType } from '../../types/connection'
import { uiText } from '../../text/uiText'
interface Props { interface Props {
serviceType: ServiceType serviceType: ServiceType
@@ -17,9 +18,10 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null) const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [searchError, setSearchError] = useState<string | null>(null)
const [syncError, setSyncError] = useState<string | null>(null)
//TODO: implement aiSearch //TODO: implement aiSearch
const [aiSearch, setAiSearch] = useState(false); const [aiSearch, setAiSearch] = useState(false)
const firstInputRef = useRef<HTMLInputElement>(null) const firstInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDialogElement>(null) const dialogRef = useRef<HTMLDialogElement>(null)
@@ -40,16 +42,27 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setError(null) setSearchError(null)
setLoading(true) setLoading(true)
try { try {
const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch} const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch}
const res = await search(req) const res = await search(req)
console.log(req)
setResults(res) setResults(res)
console.log('results', results)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Search failed') 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 { } finally {
setLoading(false) setLoading(false)
} }
@@ -58,18 +71,20 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
return ( return (
<dialog className='modal' ref={dialogRef}> <dialog className='modal' ref={dialogRef}>
<div className='modal__header'> <div className='modal__header'>
<h2 className='modal__title' id='modal-title'>Search in {label}</h2> <h2 className='modal__title' id='modal-title'>{uiText.search.title(label)}</h2>
<button className='modal__close' onClick={onClose} aria-label='Close'>×</button> <button className='modal__close' onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
</div> </div>
<form className='modal__form' onSubmit={handleSubmit}> <form className='modal__form' onSubmit={handleSubmit}>
<div className='modal__field'> <div className='modal__field'>
<input id='search' className='modal__input' <input id='search' className='modal__input'
value={query} onChange={e => setQuery(e.target.value)} /> value={query} onChange={e => setQuery(e.target.value)} />
</div> </div>
{error && <p className='modal__error'>{error}</p>} {searchError && <p className='modal__error'>{searchError}</p>}
<button className='modal__submit' type='submit' disabled={loading}> {syncError && <p className='modal__error'>{syncError}</p>}
Search <div className='modal__actions'>
</button> <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> </form>
{results && ( {results && (
<div className='modal__results'> <div className='modal__results'>
+3 -1
View File
@@ -5,7 +5,9 @@
border-radius: 6px; border-radius: 6px;
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s; transition:
box-shadow 0.2s,
opacity 0.2s;
&:hover { &:hover {
opacity: 0.85; opacity: 0.85;
+72 -48
View File
@@ -44,8 +44,13 @@
line-height: 1; line-height: 1;
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
&:hover { color: var(--text-h); } &:hover {
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } color: var(--text-h);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
} }
&__form { &__form {
@@ -94,62 +99,81 @@
border: 1px solid rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.2);
} }
&__submit { &__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px; margin-top: 4px;
}
&__submit,
&__other {
padding: 10px 20px; padding: 10px 20px;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
border-radius: 6px; border-radius: 6px;
border: none; border: none;
background: var(--accent);
color: #fff;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s;
align-self: flex-end;
min-width: 100px; min-width: 100px;
color: #fff;
&:hover:not(:disabled) { opacity: 0.85; } &:hover:not(:disabled) {
&:disabled { opacity: 0.6; cursor: not-allowed; } opacity: 0.85;
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } }
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
} }
&--expanded { &__submit {
max-height: 70vh; background: var(--accent);
} }
&__results { &--expanded {
overflow-y: auto; max-height: 70vh;
flex: 1; }
min-height: 0; // required: flex children won't shrink without this
margin-top: 20px; &__results {
display: flex; overflow-y: auto;
flex-direction: column; flex: 1;
gap: 8px; min-height: 0; // required: flex children won't shrink without this
} margin-top: 20px;
display: flex;
&__results-count { flex-direction: column;
font-size: 12px; gap: 8px;
color: var(--text); }
margin: 0 0 8px 0;
} &__results-count {
font-size: 12px;
&__result-item { color: var(--text);
padding: 10px 12px; margin: 0 0 8px 0;
border: 1px solid var(--border); }
border-radius: 6px;
background: var(--bg); &__result-item {
} padding: 10px 12px;
border: 1px solid var(--border);
&__result-title { border-radius: 6px;
font-weight: 500; background: var(--bg);
font-size: 14px; }
color: var(--text-h);
margin: 0; &__result-title {
} font-weight: 500;
font-size: 14px;
&__result-desc { color: var(--text-h);
font-size: 13px; margin: 0;
color: var(--text); }
margin: 4px 0 0 0;
} &__result-desc {
font-size: 13px;
color: var(--text);
margin: 4px 0 0 0;
}
} }
+39
View File
@@ -0,0 +1,39 @@
export const uiText = {
modal: {
closeAriaLabel: "Close",
closeSymbol: "×",
},
dashboard: {
title: "Vaessl Dashboard",
servicesLabel: "Services",
},
connections: {
connectModal: {
title: (label: string) => `Connect to ${label}`,
appUrlLabel: "App URL",
appUrlPlaceholder: "https://homebox.example.com",
usernameLabel: "Username",
passwordLabel: "Password",
loginFailed: "Login failed",
connectButton: "Connect",
connectingButton: "Connecting…",
},
serviceCard: {
connected: "Connected",
notConnected: "Not connected",
expiresPrefix: (date: string) => `· expires ${date}`,
disconnectButton: "Disconnect",
connectButton: "Connect",
searchButton: "Search",
},
},
search: {
title: (label: string) => `Search in ${label}`,
searchFailed: "Search failed",
syncFailed: "Refreshing data failed",
syncTooltip: (label: string) =>
`Syncs your ${label} database for vectorization`,
refreshDataButton: "Refresh Data",
searchButton: "Search",
},
} as const;
+6
View File
@@ -24,3 +24,9 @@ export interface PagedSearchResponse<T> {
last: boolean; last: boolean;
sort: string; sort: string;
} }
export interface SyncRequest {
appUrl: string;
username: string;
serviceType: ServiceType;
}