diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index beacec0..7d9997a 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -246,6 +246,27 @@ export type WebhookDeliveryPage = { size: number } +// === Smart search (CEO plan v1) === + +export type SearchItem = { + businessKey: string + dataScope: string + createdAt: string +} + +export type SearchDictGroup = { + dictName: string + dictDisplayName?: string | null + count: number + items: SearchItem[] +} + +export type SearchResponse = { + query: string + total: number + groups: SearchDictGroup[] +} + // === Dependents (Phase 1 dict-relationships-v2) === export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE' diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 5ced8f8..ebc533b 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -12,6 +12,7 @@ import { type RecordDependentsPage, type RecordResponse, type SchemaDependent, + type SearchResponse, type WebhookDeliveryPage, type WebhookSubscription, } from './client' @@ -363,6 +364,30 @@ export const useCascadePreview = (dict: string, businessKey: string | undefined) enabled: Boolean(businessKey), }) +// === Smart search (CEO plan v1) === + +/** + * Free-form ILIKE search across all dictionaries (active records only). + * Min query length = 3 (backend требует для использования trigram index). + */ +export const searchQuery = (q: string, size = 100, perDict = 10) => + queryOptions({ + queryKey: ['search', q, size, perDict] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/search', { + params: { q, size, perDict }, + }) + return data + }, + staleTime: 60_000, + }) + +export const useSearch = (q: string | undefined, size = 100, perDict = 10) => + useQuery({ + ...searchQuery(q ?? '', size, perDict), + enabled: Boolean(q && q.length >= 3), + }) + export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) export const useRecords = ( diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0eabfe1..1fbb268 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -17,6 +17,17 @@ i18n 'nav.audit': 'Аудит', 'nav.outbox': 'Outbox', 'nav.webhooks': 'Webhooks', + 'nav.search': 'Поиск', + 'search.title': 'Smart search', + 'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).', + 'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…', + 'search.tooShort': 'Минимум 3 символа для запуска поиска.', + 'search.empty': 'Ничего не найдено. Попробуйте другой запрос.', + 'search.totalHits_one': 'Найдено: {{count}} запись', + 'search.totalHits_few': 'Найдено: {{count}} записи', + 'search.totalHits_many': 'Найдено: {{count}} записей', + 'search.totalHits_other': 'Найдено: {{count}} записей', + 'search.intro': 'Введите запрос и нажмите Enter. Группировка результатов по справочнику; max 10 на справочник, 100 всего.', 'webhooks.title': 'Webhook subscriptions', 'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам', 'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.', @@ -378,6 +389,15 @@ i18n 'nav.audit': 'Audit', 'nav.outbox': 'Outbox', 'nav.webhooks': 'Webhooks', + 'nav.search': 'Search', + 'search.title': 'Smart search', + 'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).', + 'search.placeholder': 'Type 3+ chars: code, name, identifier…', + 'search.tooShort': 'Type at least 3 characters to run search.', + 'search.empty': 'No results. Try a different query.', + 'search.totalHits_one': '{{count}} record found', + 'search.totalHits_other': '{{count}} records found', + 'search.intro': 'Type a query and press Enter. Results grouped per dictionary; max 10 per dictionary, 100 total.', 'webhooks.title': 'Webhook subscriptions', 'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers', 'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index bfecb6a..556f9c9 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebhooksRouteImport } from './routes/webhooks' +import { Route as SearchRouteImport } from './routes/search' import { Route as OutboxRouteImport } from './routes/outbox' import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as AuditRouteImport } from './routes/audit' @@ -24,6 +25,11 @@ const WebhooksRoute = WebhooksRouteImport.update({ path: '/webhooks', getParentRoute: () => rootRouteImport, } as any) +const SearchRoute = SearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => rootRouteImport, +} as any) const OutboxRoute = OutboxRouteImport.update({ id: '/outbox', path: '/outbox', @@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren '/outbox': typeof OutboxRoute + '/search': typeof SearchRoute '/webhooks': typeof WebhooksRouteWithChildren '/dictionaries/$name': typeof DictionariesNameRoute '/webhooks/$id': typeof WebhooksIdRoute @@ -80,6 +87,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/audit': typeof AuditRoute '/outbox': typeof OutboxRoute + '/search': typeof SearchRoute '/dictionaries/$name': typeof DictionariesNameRoute '/webhooks/$id': typeof WebhooksIdRoute '/dictionaries': typeof DictionariesIndexRoute @@ -91,6 +99,7 @@ export interface FileRoutesById { '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren '/outbox': typeof OutboxRoute + '/search': typeof SearchRoute '/webhooks': typeof WebhooksRouteWithChildren '/dictionaries/$name': typeof DictionariesNameRoute '/webhooks/$id': typeof WebhooksIdRoute @@ -104,6 +113,7 @@ export interface FileRouteTypes { | '/audit' | '/dictionaries' | '/outbox' + | '/search' | '/webhooks' | '/dictionaries/$name' | '/webhooks/$id' @@ -114,6 +124,7 @@ export interface FileRouteTypes { | '/' | '/audit' | '/outbox' + | '/search' | '/dictionaries/$name' | '/webhooks/$id' | '/dictionaries' @@ -124,6 +135,7 @@ export interface FileRouteTypes { | '/audit' | '/dictionaries' | '/outbox' + | '/search' | '/webhooks' | '/dictionaries/$name' | '/webhooks/$id' @@ -136,6 +148,7 @@ export interface RootRouteChildren { AuditRoute: typeof AuditRoute DictionariesRoute: typeof DictionariesRouteWithChildren OutboxRoute: typeof OutboxRoute + SearchRoute: typeof SearchRoute WebhooksRoute: typeof WebhooksRouteWithChildren } @@ -148,6 +161,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebhooksRouteImport parentRoute: typeof rootRouteImport } + '/search': { + id: '/search' + path: '/search' + fullPath: '/search' + preLoaderRoute: typeof SearchRouteImport + parentRoute: typeof rootRouteImport + } '/outbox': { id: '/outbox' path: '/outbox' @@ -240,6 +260,7 @@ const rootRouteChildren: RootRouteChildren = { AuditRoute: AuditRoute, DictionariesRoute: DictionariesRouteWithChildren, OutboxRoute: OutboxRoute, + SearchRoute: SearchRoute, WebhooksRoute: WebhooksRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 4b4f698..e03e009 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -51,6 +51,13 @@ function RootLayout() { > {t('nav.webhooks')} + + {t('nav.search')} +
): SearchSearch => { + if (typeof raw.q === 'string' && raw.q.length > 0) return { q: raw.q } + return {} +} + +export const Route = createFileRoute('/search')({ + component: SearchPage, + validateSearch, +}) + +/** + * Smart JSONB search across all dictionaries (CEO plan v1 stretch). + * + *

Backend: GET /api/v1/search?q=&size&perDict — ILIKE с trigram-индексом + * на dictionary_records.data::text. Min query length = 3 (trigram threshold). + * + *

UI: search input → grouped results per dict с per-row link на full + * record. URL state ?q=… — share-friendly. + */ +function SearchPage() { + const { t } = useTranslation() + const urlSearch = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const [input, setInput] = useState(urlSearch.q ?? '') + + const result = useSearch(urlSearch.q) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = input.trim() + void navigate({ search: trimmed ? { q: trimmed } : {}, replace: true }) + } + + const tooShort = input.length > 0 && input.length < 3 + const hasQuery = Boolean(urlSearch.q && urlSearch.q.length >= 3) + + return ( +

+ + +
+
+ setInput(e.target.value)} + placeholder={t('search.placeholder')} + aria-label={t('search.placeholder')} + autoFocus + /> +
+
+ + {tooShort && ( +

{t('search.tooShort')}

+ )} + + {hasQuery && result.isLoading && ( + + )} + + {hasQuery && result.error && ( + + {String(result.error)} + + )} + + {hasQuery && result.data && result.data.total === 0 && ( + + )} + + {hasQuery && result.data && result.data.total > 0 && ( + <> +

+ {t('search.totalHits', { count: result.data.total })} +

+
+ {result.data.groups.map((g) => ( + +
+ + {g.dictDisplayName ?? g.dictName} + + {g.count} +
+
    + {g.items.map((it) => ( +
  • + + {it.businessKey} + +
    + {it.dataScope} + + {new Date(it.createdAt).toLocaleString()} + +
    +
  • + ))} +
+
+ ))} +
+ + )} + + {!hasQuery && !tooShort && ( +

{t('search.intro')}

+ )} +
+ ) +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/RecordSearchQuery.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/RecordSearchQuery.java new file mode 100644 index 0000000..95e65e1 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/RecordSearchQuery.java @@ -0,0 +1,102 @@ +package cloud.nstart.terravault.ordinis.domain.record; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Smart JSONB search across all dictionaries (CEO plan v1 stretch). + * + *

ILIKE на {@code data::text} с trigram-индексом + * {@code idx_dict_records_data_trgm} (см. migration 0018). Active records + * only ({@code valid_from <= now() < valid_to}), фильтрация по data_scope + * запрашивающего consumer'а. + * + *

Per-dict cap: max {@code maxPerDict} matches на каждый словарь — + * иначе один dict с тысячей matches забьёт весь limit. + * + *

SQL injection: query passed как PreparedStatement param (`?`), wrap + * с `%...%` в SQL constant. Никаких user inputs не идут в SQL string. + */ +@Component +public class RecordSearchQuery { + + private final JdbcTemplate jdbc; + + @Autowired + public RecordSearchQuery(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * Search active records по {@code q} string. + * + * @param q free-form text (>= 3 chars для использования индекса) + * @param allowedScopes scope subset виден caller'у (как text values) + * @param at snapshot moment (now() обычно) + * @param maxPerDict cap matches per dict (защита от dominant-dict skew) + * @param totalLimit hard cap всех результатов + */ + public List search( + String q, + Collection allowedScopes, + OffsetDateTime at, + int maxPerDict, + int totalLimit) { + if (q == null || q.length() < 3) { + return List.of(); + } + if (maxPerDict < 1 || maxPerDict > 100) maxPerDict = 10; + if (totalLimit < 1 || totalLimit > 500) totalLimit = 100; + if (allowedScopes == null || allowedScopes.isEmpty()) return List.of(); + + // Per-dict ROW_NUMBER cap. window function over partition, order by + // created_at DESC чтобы newest matches видны первыми. + String sql = """ + SELECT * FROM ( + SELECT + r.id, r.dictionary_id, def.name AS dict_name, def.display_name AS dict_display, + r.business_key, r.data_scope, r.created_at, + ROW_NUMBER() OVER (PARTITION BY r.dictionary_id ORDER BY r.created_at DESC) AS rn + FROM dictionary_records r + JOIN dictionary_definitions def ON def.id = r.dictionary_id + WHERE r.valid_from <= ? + AND r.valid_to > ? + AND r.data_scope = ANY(CAST(? AS TEXT[])) + AND r.data::text ILIKE ? + ) t + WHERE rn <= ? + ORDER BY t.dict_name, t.created_at DESC + LIMIT ? + """; + + String[] scopesArr = allowedScopes.toArray(String[]::new); + String pattern = "%" + q + "%"; + + return jdbc.query( + sql, + (rs, n) -> new SearchHit( + (UUID) rs.getObject("id"), + (UUID) rs.getObject("dictionary_id"), + rs.getString("dict_name"), + rs.getString("dict_display"), + rs.getString("business_key"), + rs.getString("data_scope"), + rs.getObject("created_at", OffsetDateTime.class)), + at, at, scopesArr, pattern, maxPerDict, totalLimit); + } + + public record SearchHit( + UUID id, + UUID dictionaryId, + String dictName, + String dictDisplayName, + String businessKey, + String dataScope, + OffsetDateTime createdAt) {} +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0018-trgm-search-index.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0018-trgm-search-index.xml new file mode 100644 index 0000000..857598e --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0018-trgm-search-index.xml @@ -0,0 +1,51 @@ + + + + + + + + + SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') + + + + Enable pg_trgm для trigram-based ILIKE search + CREATE EXTENSION IF NOT EXISTS pg_trgm; + + + SELECT 1; + + + + + GIN trigram index на dictionary_records.data::text для smart search + + CREATE INDEX idx_dict_records_data_trgm + ON dictionary_records + USING GIN ((data::text) gin_trgm_ops); + + + DROP INDEX IF EXISTS idx_dict_records_data_trgm; + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index c12512c..781ea28 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -27,5 +27,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SearchController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SearchController.java new file mode 100644 index 0000000..8a86f18 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SearchController.java @@ -0,0 +1,98 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.record.RecordSearchQuery; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Smart search across all dictionaries (CEO plan v1 stretch). + * + *

{@code GET /api/v1/search?q=…&size=…&perDict=…} — free-form ILIKE + * по {@code dictionary_records.data::text} с trigram индексом. Возвращает + * grouped results per dict {@link RecordSearchQuery.SearchHit}. + * + *

Min query length = 3 (чтобы использовать trigram index). Backend + * silently возвращает empty list для shorter queries. + * + *

Scope filter: только dicts которых caller видит через scope. + * + *

Phase v1 limits: max 10 per dict, max 100 total. Достаточно для admin + * "find this code" use case. Для browser-style search-as-you-type — нужен + * dedicated index server (Elastic / Meili) — отложен в v2. + */ +@RestController +@RequestMapping("/api/v1/search") +public class SearchController { + + private final RecordSearchQuery searchQuery; + private final ScopeContext scopeContext; + + public SearchController(RecordSearchQuery searchQuery, ScopeContext scopeContext) { + this.searchQuery = searchQuery; + this.scopeContext = scopeContext; + } + + /** + * @param q free-form text (min 3 chars; <3 returns empty). + * @param size total cap (default 100, max 500). + * @param perDict per-dictionary cap (default 10, max 100). + * @return grouped по dict_name с display name + count + items. + */ + @GetMapping + public SearchResponse search( + @RequestParam String q, + @RequestParam(defaultValue = "100") int size, + @RequestParam(defaultValue = "10") int perDict) { + var scopeStrings = scopeContext.currentScopes().stream() + .map(DataScope::name) + // DB column data_scope использует lowercase per CHECK constraint + // ("public"/"internal"/"restricted"). Mapping enum → lowercase. + .map(String::toLowerCase) + .collect(Collectors.toList()); + + List hits = searchQuery.search( + q, scopeStrings, OffsetDateTime.now(), perDict, size); + + // Group by dict for clean UI rendering. + Map> grouped = hits.stream() + .collect(Collectors.groupingBy( + RecordSearchQuery.SearchHit::dictName, + java.util.LinkedHashMap::new, + Collectors.toList())); + + List groups = grouped.entrySet().stream() + .map(e -> { + var first = e.getValue().get(0); + return new DictGroup( + e.getKey(), + first.dictDisplayName(), + e.getValue().size(), + e.getValue().stream() + .map(h -> new SearchItem( + h.businessKey(), h.dataScope(), h.createdAt())) + .toList()); + }) + .toList(); + + return new SearchResponse(q, hits.size(), groups); + } + + public record SearchItem(String businessKey, String dataScope, OffsetDateTime createdAt) {} + + public record DictGroup( + String dictName, + String dictDisplayName, + int count, + List items) {} + + public record SearchResponse(String query, int total, List groups) {} +}