@86d-app/search 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/search/src/__tests__/admin-settings.test.js +262 -0
- package/dist/modules/search/src/__tests__/controllers.test.js +853 -0
- package/dist/modules/search/src/__tests__/embedding-provider.test.js +150 -0
- package/dist/modules/search/src/__tests__/endpoint-security.test.js +250 -0
- package/dist/modules/search/src/__tests__/meilisearch-provider.test.js +318 -0
- package/dist/modules/search/src/__tests__/service-impl.test.js +703 -0
- package/dist/modules/search/src/__tests__/store-endpoints.test.js +295 -0
- package/dist/{admin/components/index.d.ts → modules/search/src/admin/components/index.jsx} +0 -1
- package/dist/modules/search/src/admin/components/search-analytics.jsx +230 -0
- package/dist/modules/search/src/admin/endpoints/analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/bulk-index.js +26 -0
- package/dist/modules/search/src/admin/endpoints/click-analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/get-settings.js +97 -0
- package/dist/modules/search/src/admin/endpoints/index-manage.js +32 -0
- package/dist/modules/search/src/admin/endpoints/index.js +21 -0
- package/dist/modules/search/src/admin/endpoints/popular.js +11 -0
- package/dist/modules/search/src/admin/endpoints/synonyms.js +30 -0
- package/dist/modules/search/src/admin/endpoints/zero-results.js +11 -0
- package/dist/modules/search/src/embedding-provider.js +77 -0
- package/dist/modules/search/src/index.js +75 -0
- package/dist/modules/search/src/meilisearch-provider.js +138 -0
- package/dist/modules/search/src/schema.js +61 -0
- package/dist/modules/search/src/service-impl.js +770 -0
- package/dist/modules/search/src/service.js +1 -0
- package/dist/modules/search/src/store/components/_hooks.js +10 -0
- package/dist/modules/search/src/store/components/index.jsx +9 -0
- package/dist/modules/search/src/store/components/search-bar.jsx +91 -0
- package/dist/modules/search/src/store/components/search-page.jsx +17 -0
- package/dist/modules/search/src/store/components/search-results.jsx +51 -0
- package/dist/modules/search/src/store/endpoints/click.js +15 -0
- package/dist/modules/search/src/store/endpoints/index.js +12 -0
- package/dist/modules/search/src/store/endpoints/recent.js +18 -0
- package/dist/modules/search/src/store/endpoints/search.js +57 -0
- package/dist/modules/search/src/store/endpoints/store-search.js +33 -0
- package/dist/modules/search/src/store/endpoints/suggest.js +12 -0
- package/package.json +1 -1
- package/src/__tests__/admin-settings.test.ts +367 -0
- package/src/__tests__/store-endpoints.test.ts +392 -0
- package/src/admin/endpoints/get-settings.ts +77 -0
- package/dist/__tests__/controllers.test.d.ts +0 -2
- package/dist/__tests__/controllers.test.d.ts.map +0 -1
- package/dist/__tests__/embedding-provider.test.d.ts +0 -2
- package/dist/__tests__/embedding-provider.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint-security.test.d.ts +0 -2
- package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
- package/dist/__tests__/meilisearch-provider.test.d.ts +0 -2
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +0 -1
- package/dist/__tests__/service-impl.test.d.ts +0 -2
- package/dist/__tests__/service-impl.test.d.ts.map +0 -1
- package/dist/admin/components/index.d.ts.map +0 -1
- package/dist/admin/components/search-analytics.d.ts +0 -2
- package/dist/admin/components/search-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/analytics.d.ts +0 -15
- package/dist/admin/endpoints/analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/bulk-index.d.ts +0 -20
- package/dist/admin/endpoints/bulk-index.d.ts.map +0 -1
- package/dist/admin/endpoints/click-analytics.d.ts +0 -7
- package/dist/admin/endpoints/click-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/get-settings.d.ts +0 -17
- package/dist/admin/endpoints/get-settings.d.ts.map +0 -1
- package/dist/admin/endpoints/index-manage.d.ts +0 -26
- package/dist/admin/endpoints/index-manage.d.ts.map +0 -1
- package/dist/admin/endpoints/index.d.ts +0 -125
- package/dist/admin/endpoints/index.d.ts.map +0 -1
- package/dist/admin/endpoints/popular.d.ts +0 -10
- package/dist/admin/endpoints/popular.d.ts.map +0 -1
- package/dist/admin/endpoints/synonyms.d.ts +0 -30
- package/dist/admin/endpoints/synonyms.d.ts.map +0 -1
- package/dist/admin/endpoints/zero-results.d.ts +0 -10
- package/dist/admin/endpoints/zero-results.d.ts.map +0 -1
- package/dist/embedding-provider.d.ts +0 -28
- package/dist/embedding-provider.d.ts.map +0 -1
- package/dist/index.d.ts +0 -23
- package/dist/index.d.ts.map +0 -1
- package/dist/meilisearch-provider.d.ts +0 -104
- package/dist/meilisearch-provider.d.ts.map +0 -1
- package/dist/schema.d.ts +0 -133
- package/dist/schema.d.ts.map +0 -1
- package/dist/service-impl.d.ts +0 -6
- package/dist/service-impl.d.ts.map +0 -1
- package/dist/service.d.ts +0 -127
- package/dist/service.d.ts.map +0 -1
- package/dist/store/components/_hooks.d.ts +0 -6
- package/dist/store/components/_hooks.d.ts.map +0 -1
- package/dist/store/components/index.d.ts +0 -10
- package/dist/store/components/index.d.ts.map +0 -1
- package/dist/store/components/search-bar.d.ts +0 -7
- package/dist/store/components/search-bar.d.ts.map +0 -1
- package/dist/store/components/search-page.d.ts +0 -4
- package/dist/store/components/search-page.d.ts.map +0 -1
- package/dist/store/components/search-results.d.ts +0 -9
- package/dist/store/components/search-results.d.ts.map +0 -1
- package/dist/store/endpoints/click.d.ts +0 -14
- package/dist/store/endpoints/click.d.ts.map +0 -1
- package/dist/store/endpoints/index.d.ts +0 -85
- package/dist/store/endpoints/index.d.ts.map +0 -1
- package/dist/store/endpoints/recent.d.ts +0 -15
- package/dist/store/endpoints/recent.d.ts.map +0 -1
- package/dist/store/endpoints/search.d.ts +0 -36
- package/dist/store/endpoints/search.d.ts.map +0 -1
- package/dist/store/endpoints/store-search.d.ts +0 -16
- package/dist/store/endpoints/store-search.d.ts.map +0 -1
- package/dist/store/endpoints/suggest.d.ts +0 -11
- package/dist/store/endpoints/suggest.d.ts.map +0 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createSearchController } from "../service-impl";
|
|
4
|
+
// ── Simulate store endpoint logic ─────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Simulates the search endpoint: parses tags from comma-separated
|
|
7
|
+
* string, delegates to controller, shapes response by stripping
|
|
8
|
+
* internal fields.
|
|
9
|
+
*/
|
|
10
|
+
async function simulateSearch(data, query) {
|
|
11
|
+
const controller = createSearchController(data);
|
|
12
|
+
const parsedTags = query.tags
|
|
13
|
+
? query.tags
|
|
14
|
+
.split(",")
|
|
15
|
+
.map((t) => t.trim())
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
: undefined;
|
|
18
|
+
const { results, total, facets, didYouMean } = await controller.search(query.q, {
|
|
19
|
+
entityType: query.type,
|
|
20
|
+
tags: parsedTags,
|
|
21
|
+
sort: query.sort,
|
|
22
|
+
fuzzy: query.fuzzy,
|
|
23
|
+
limit: query.limit ?? 20,
|
|
24
|
+
skip: query.skip ?? 0,
|
|
25
|
+
});
|
|
26
|
+
// Fire-and-forget analytics recording
|
|
27
|
+
controller.recordQuery(query.q, total, query.sessionId).catch(() => { });
|
|
28
|
+
return {
|
|
29
|
+
results: results.map((r) => ({
|
|
30
|
+
id: r.item.id,
|
|
31
|
+
entityType: r.item.entityType,
|
|
32
|
+
entityId: r.item.entityId,
|
|
33
|
+
title: r.item.title,
|
|
34
|
+
url: r.item.url,
|
|
35
|
+
image: r.item.image,
|
|
36
|
+
tags: r.item.tags,
|
|
37
|
+
score: r.score,
|
|
38
|
+
highlights: r.highlights,
|
|
39
|
+
})),
|
|
40
|
+
total,
|
|
41
|
+
facets,
|
|
42
|
+
didYouMean,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Simulates the suggest endpoint.
|
|
47
|
+
*/
|
|
48
|
+
async function simulateSuggest(data, q, limit) {
|
|
49
|
+
const controller = createSearchController(data);
|
|
50
|
+
const suggestions = await controller.suggest(q, limit ?? 8);
|
|
51
|
+
return { suggestions };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Simulates the click endpoint.
|
|
55
|
+
*/
|
|
56
|
+
async function simulateClick(data, body) {
|
|
57
|
+
const controller = createSearchController(data);
|
|
58
|
+
const click = await controller.recordClick(body);
|
|
59
|
+
return { id: click.id };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Simulates the recent endpoint: returns shaped recent queries.
|
|
63
|
+
*/
|
|
64
|
+
async function simulateRecent(data, sessionId, limit) {
|
|
65
|
+
const controller = createSearchController(data);
|
|
66
|
+
const queries = await controller.getRecentQueries(sessionId, limit ?? 10);
|
|
67
|
+
return {
|
|
68
|
+
recent: queries.map((q) => ({
|
|
69
|
+
term: q.term,
|
|
70
|
+
resultCount: q.resultCount,
|
|
71
|
+
searchedAt: q.searchedAt,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
76
|
+
async function seedIndexItems(data) {
|
|
77
|
+
const controller = createSearchController(data);
|
|
78
|
+
await controller.indexItem({
|
|
79
|
+
entityType: "product",
|
|
80
|
+
entityId: "prod-1",
|
|
81
|
+
title: "Red Widget",
|
|
82
|
+
body: "A fantastic red widget for everyday use",
|
|
83
|
+
tags: ["electronics", "sale"],
|
|
84
|
+
url: "/products/red-widget",
|
|
85
|
+
image: "/img/red-widget.jpg",
|
|
86
|
+
});
|
|
87
|
+
await controller.indexItem({
|
|
88
|
+
entityType: "product",
|
|
89
|
+
entityId: "prod-2",
|
|
90
|
+
title: "Blue Gadget",
|
|
91
|
+
body: "The best blue gadget on the market",
|
|
92
|
+
tags: ["electronics", "new"],
|
|
93
|
+
url: "/products/blue-gadget",
|
|
94
|
+
});
|
|
95
|
+
await controller.indexItem({
|
|
96
|
+
entityType: "page",
|
|
97
|
+
entityId: "page-1",
|
|
98
|
+
title: "About Us",
|
|
99
|
+
body: "Learn about our company",
|
|
100
|
+
tags: ["info"],
|
|
101
|
+
url: "/about",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// ── Tests ─────────────────────────────────────────────────────────────
|
|
105
|
+
describe("search store endpoints", () => {
|
|
106
|
+
let data;
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
data = createMockDataService();
|
|
109
|
+
});
|
|
110
|
+
// ── search ───────────────────────────────────────────────────────
|
|
111
|
+
describe("search", () => {
|
|
112
|
+
it("returns results matching query", async () => {
|
|
113
|
+
await seedIndexItems(data);
|
|
114
|
+
const result = await simulateSearch(data, { q: "widget" });
|
|
115
|
+
expect(result.total).toBeGreaterThan(0);
|
|
116
|
+
expect(result.results[0].title).toContain("Widget");
|
|
117
|
+
});
|
|
118
|
+
it("shapes response by stripping internal fields", async () => {
|
|
119
|
+
await seedIndexItems(data);
|
|
120
|
+
const result = await simulateSearch(data, { q: "red" });
|
|
121
|
+
const firstResult = result.results[0];
|
|
122
|
+
// Should include these fields
|
|
123
|
+
expect(firstResult).toHaveProperty("id");
|
|
124
|
+
expect(firstResult).toHaveProperty("entityType");
|
|
125
|
+
expect(firstResult).toHaveProperty("entityId");
|
|
126
|
+
expect(firstResult).toHaveProperty("title");
|
|
127
|
+
expect(firstResult).toHaveProperty("url");
|
|
128
|
+
expect(firstResult).toHaveProperty("tags");
|
|
129
|
+
expect(firstResult).toHaveProperty("score");
|
|
130
|
+
// Should NOT include body or metadata (internal fields)
|
|
131
|
+
expect(firstResult).not.toHaveProperty("body");
|
|
132
|
+
expect(firstResult).not.toHaveProperty("metadata");
|
|
133
|
+
expect(firstResult).not.toHaveProperty("indexedAt");
|
|
134
|
+
});
|
|
135
|
+
it("filters by entity type", async () => {
|
|
136
|
+
await seedIndexItems(data);
|
|
137
|
+
const result = await simulateSearch(data, {
|
|
138
|
+
q: "about",
|
|
139
|
+
type: "page",
|
|
140
|
+
});
|
|
141
|
+
for (const r of result.results) {
|
|
142
|
+
expect(r.entityType).toBe("page");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it("parses comma-separated tags into array", async () => {
|
|
146
|
+
await seedIndexItems(data);
|
|
147
|
+
const result = await simulateSearch(data, {
|
|
148
|
+
q: "widget",
|
|
149
|
+
tags: "electronics, sale",
|
|
150
|
+
});
|
|
151
|
+
// Should find items tagged with electronics AND sale
|
|
152
|
+
expect(result.total).toBeGreaterThanOrEqual(0);
|
|
153
|
+
});
|
|
154
|
+
it("handles tags with extra whitespace and empty segments", async () => {
|
|
155
|
+
await seedIndexItems(data);
|
|
156
|
+
// "electronics,, new, " should parse to ["electronics", "new"]
|
|
157
|
+
const result = await simulateSearch(data, {
|
|
158
|
+
q: "gadget",
|
|
159
|
+
tags: "electronics,, new, ",
|
|
160
|
+
});
|
|
161
|
+
expect(result.total).toBeGreaterThanOrEqual(0);
|
|
162
|
+
});
|
|
163
|
+
it("returns empty results for no-match query", async () => {
|
|
164
|
+
await seedIndexItems(data);
|
|
165
|
+
const result = await simulateSearch(data, {
|
|
166
|
+
q: "xyznonexistent",
|
|
167
|
+
});
|
|
168
|
+
expect(result.results).toHaveLength(0);
|
|
169
|
+
expect(result.total).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
it("respects limit and skip pagination", async () => {
|
|
172
|
+
await seedIndexItems(data);
|
|
173
|
+
const page1 = await simulateSearch(data, {
|
|
174
|
+
q: "widget gadget about",
|
|
175
|
+
limit: 1,
|
|
176
|
+
skip: 0,
|
|
177
|
+
});
|
|
178
|
+
const page2 = await simulateSearch(data, {
|
|
179
|
+
q: "widget gadget about",
|
|
180
|
+
limit: 1,
|
|
181
|
+
skip: 1,
|
|
182
|
+
});
|
|
183
|
+
// Pages should return at most 1 result each
|
|
184
|
+
expect(page1.results.length).toBeLessThanOrEqual(1);
|
|
185
|
+
expect(page2.results.length).toBeLessThanOrEqual(1);
|
|
186
|
+
});
|
|
187
|
+
it("returns facets with result counts", async () => {
|
|
188
|
+
await seedIndexItems(data);
|
|
189
|
+
const result = await simulateSearch(data, { q: "widget" });
|
|
190
|
+
expect(result.facets).toBeDefined();
|
|
191
|
+
expect(result.facets.entityTypes).toBeDefined();
|
|
192
|
+
expect(result.facets.tags).toBeDefined();
|
|
193
|
+
});
|
|
194
|
+
it("includes image field when present", async () => {
|
|
195
|
+
await seedIndexItems(data);
|
|
196
|
+
const result = await simulateSearch(data, { q: "red widget" });
|
|
197
|
+
const redWidget = result.results.find((r) => r.entityId === "prod-1");
|
|
198
|
+
if (redWidget) {
|
|
199
|
+
expect(redWidget.image).toBe("/img/red-widget.jpg");
|
|
200
|
+
}
|
|
201
|
+
const blueGadget = result.results.find((r) => r.entityId === "prod-2");
|
|
202
|
+
if (blueGadget) {
|
|
203
|
+
expect(blueGadget.image).toBeUndefined();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
// ── suggest ──────────────────────────────────────────────────────
|
|
208
|
+
describe("suggest", () => {
|
|
209
|
+
it("returns suggestions for prefix", async () => {
|
|
210
|
+
await seedIndexItems(data);
|
|
211
|
+
const result = await simulateSuggest(data, "wid");
|
|
212
|
+
expect(result.suggestions).toBeDefined();
|
|
213
|
+
expect(Array.isArray(result.suggestions)).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
it("returns empty for no-match prefix", async () => {
|
|
216
|
+
await seedIndexItems(data);
|
|
217
|
+
const result = await simulateSuggest(data, "zzz");
|
|
218
|
+
expect(result.suggestions).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
it("respects custom limit", async () => {
|
|
221
|
+
await seedIndexItems(data);
|
|
222
|
+
const result = await simulateSuggest(data, "a", 2);
|
|
223
|
+
expect(result.suggestions.length).toBeLessThanOrEqual(2);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
// ── click ────────────────────────────────────────────────────────
|
|
227
|
+
describe("click", () => {
|
|
228
|
+
it("records a click and returns id", async () => {
|
|
229
|
+
const result = await simulateClick(data, {
|
|
230
|
+
queryId: "q-1",
|
|
231
|
+
term: "widget",
|
|
232
|
+
entityType: "product",
|
|
233
|
+
entityId: "prod-1",
|
|
234
|
+
position: 0,
|
|
235
|
+
});
|
|
236
|
+
expect(result.id).toBeDefined();
|
|
237
|
+
expect(typeof result.id).toBe("string");
|
|
238
|
+
});
|
|
239
|
+
it("records multiple clicks for same query", async () => {
|
|
240
|
+
const click1 = await simulateClick(data, {
|
|
241
|
+
queryId: "q-1",
|
|
242
|
+
term: "widget",
|
|
243
|
+
entityType: "product",
|
|
244
|
+
entityId: "prod-1",
|
|
245
|
+
position: 0,
|
|
246
|
+
});
|
|
247
|
+
const click2 = await simulateClick(data, {
|
|
248
|
+
queryId: "q-1",
|
|
249
|
+
term: "widget",
|
|
250
|
+
entityType: "product",
|
|
251
|
+
entityId: "prod-2",
|
|
252
|
+
position: 1,
|
|
253
|
+
});
|
|
254
|
+
expect(click1.id).not.toBe(click2.id);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
// ── recent ───────────────────────────────────────────────────────
|
|
258
|
+
describe("recent", () => {
|
|
259
|
+
it("returns empty for new session", async () => {
|
|
260
|
+
const result = await simulateRecent(data, "session-new");
|
|
261
|
+
expect(result.recent).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
it("returns shaped recent queries (term, resultCount, searchedAt only)", async () => {
|
|
264
|
+
const controller = createSearchController(data);
|
|
265
|
+
await controller.recordQuery("widgets", 5, "session-1");
|
|
266
|
+
await controller.recordQuery("gadgets", 3, "session-1");
|
|
267
|
+
const result = await simulateRecent(data, "session-1");
|
|
268
|
+
expect(result.recent.length).toBeGreaterThan(0);
|
|
269
|
+
const entry = result.recent[0];
|
|
270
|
+
expect(entry).toHaveProperty("term");
|
|
271
|
+
expect(entry).toHaveProperty("resultCount");
|
|
272
|
+
expect(entry).toHaveProperty("searchedAt");
|
|
273
|
+
// Should NOT include internal fields
|
|
274
|
+
expect(entry).not.toHaveProperty("id");
|
|
275
|
+
expect(entry).not.toHaveProperty("normalizedTerm");
|
|
276
|
+
expect(entry).not.toHaveProperty("sessionId");
|
|
277
|
+
});
|
|
278
|
+
it("scopes queries to session", async () => {
|
|
279
|
+
const controller = createSearchController(data);
|
|
280
|
+
await controller.recordQuery("widgets", 5, "session-1");
|
|
281
|
+
await controller.recordQuery("gadgets", 3, "session-2");
|
|
282
|
+
const result = await simulateRecent(data, "session-1");
|
|
283
|
+
expect(result.recent).toHaveLength(1);
|
|
284
|
+
expect(result.recent[0].term).toBe("widgets");
|
|
285
|
+
});
|
|
286
|
+
it("respects limit parameter", async () => {
|
|
287
|
+
const controller = createSearchController(data);
|
|
288
|
+
for (let i = 0; i < 5; i++) {
|
|
289
|
+
await controller.recordQuery(`term-${i}`, i, "session-1");
|
|
290
|
+
}
|
|
291
|
+
const result = await simulateRecent(data, "session-1", 2);
|
|
292
|
+
expect(result.recent.length).toBeLessThanOrEqual(2);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
function useSearchAdminApi() {
|
|
5
|
+
const client = useModuleClient();
|
|
6
|
+
return {
|
|
7
|
+
settings: client.module("search").admin["/admin/search/settings"],
|
|
8
|
+
analytics: client.module("search").admin["/admin/search/analytics"],
|
|
9
|
+
popular: client.module("search").admin["/admin/search/popular"],
|
|
10
|
+
zeroResults: client.module("search").admin["/admin/search/zero-results"],
|
|
11
|
+
synonyms: client.module("search").admin["/admin/search/synonyms"],
|
|
12
|
+
addSynonym: client.module("search").admin["/admin/search/synonyms/add"],
|
|
13
|
+
removeSynonym: client.module("search").admin["/admin/search/synonyms/:id/delete"],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function StatCard({ label, value }) {
|
|
17
|
+
return (<div className="rounded-lg border border-border bg-background p-4">
|
|
18
|
+
<p className="text-muted-foreground text-xs uppercase tracking-wider">
|
|
19
|
+
{label}
|
|
20
|
+
</p>
|
|
21
|
+
<p className="mt-1 font-semibold text-2xl text-foreground">{value}</p>
|
|
22
|
+
</div>);
|
|
23
|
+
}
|
|
24
|
+
function ConfigBadge({ configured, label, }) {
|
|
25
|
+
return (<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-medium text-xs ${configured
|
|
26
|
+
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
27
|
+
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"}`}>
|
|
28
|
+
<span className={`inline-block size-1.5 rounded-full ${configured ? "bg-emerald-500" : "bg-amber-500"}`}/>
|
|
29
|
+
{label}
|
|
30
|
+
</span>);
|
|
31
|
+
}
|
|
32
|
+
export function SearchAnalytics() {
|
|
33
|
+
const api = useSearchAdminApi();
|
|
34
|
+
const [newTerm, setNewTerm] = useState("");
|
|
35
|
+
const [newSynonyms, setNewSynonyms] = useState("");
|
|
36
|
+
const [error, setError] = useState("");
|
|
37
|
+
const { data: settingsData } = api.settings.useQuery({});
|
|
38
|
+
const { data: analyticsData, isLoading: analyticsLoading } = api.analytics.useQuery({});
|
|
39
|
+
const { data: popularData, isLoading: popularLoading } = api.popular.useQuery({ limit: "15" });
|
|
40
|
+
const { data: zeroData, isLoading: zeroLoading } = api.zeroResults.useQuery({
|
|
41
|
+
limit: "15",
|
|
42
|
+
});
|
|
43
|
+
const { data: synonymsData, isLoading: synonymsLoading } = api.synonyms.useQuery({});
|
|
44
|
+
const addMutation = api.addSynonym.useMutation({
|
|
45
|
+
onSettled: () => {
|
|
46
|
+
void api.synonyms.invalidate();
|
|
47
|
+
setNewTerm("");
|
|
48
|
+
setNewSynonyms("");
|
|
49
|
+
},
|
|
50
|
+
onError: () => {
|
|
51
|
+
setError("Failed to add synonym.");
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const removeMutation = api.removeSynonym.useMutation({
|
|
55
|
+
onSettled: () => {
|
|
56
|
+
void api.synonyms.invalidate();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const analytics = analyticsData?.analytics;
|
|
60
|
+
const popularTerms = popularData?.terms ?? [];
|
|
61
|
+
const zeroResultTerms = zeroData?.terms ?? [];
|
|
62
|
+
const synonyms = synonymsData?.synonyms ?? [];
|
|
63
|
+
const loading = analyticsLoading || popularLoading || zeroLoading || synonymsLoading;
|
|
64
|
+
const handleAddSynonym = () => {
|
|
65
|
+
setError("");
|
|
66
|
+
const term = newTerm.trim();
|
|
67
|
+
const syns = newSynonyms
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((s) => s.trim())
|
|
70
|
+
.filter((s) => s.length > 0);
|
|
71
|
+
if (!term || syns.length === 0) {
|
|
72
|
+
setError("Enter a term and at least one synonym.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
addMutation.mutate({ term, synonyms: syns });
|
|
76
|
+
};
|
|
77
|
+
if (loading && !analytics) {
|
|
78
|
+
return (<div className="py-16 text-center">
|
|
79
|
+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-foreground"/>
|
|
80
|
+
<p className="mt-4 text-muted-foreground text-sm">
|
|
81
|
+
Loading search analytics...
|
|
82
|
+
</p>
|
|
83
|
+
</div>);
|
|
84
|
+
}
|
|
85
|
+
return (<div className="space-y-8">
|
|
86
|
+
{/* Search engine configuration */}
|
|
87
|
+
{settingsData && (<div className="rounded-lg border border-border bg-background p-5">
|
|
88
|
+
<h3 className="mb-4 font-medium text-foreground text-sm">
|
|
89
|
+
Search Configuration
|
|
90
|
+
</h3>
|
|
91
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
92
|
+
<div className="flex flex-col gap-2">
|
|
93
|
+
<div className="flex items-center justify-between">
|
|
94
|
+
<span className="text-muted-foreground text-sm">
|
|
95
|
+
MeiliSearch
|
|
96
|
+
</span>
|
|
97
|
+
<ConfigBadge configured={settingsData.meilisearch.configured} label={settingsData.meilisearch.configured
|
|
98
|
+
? "Connected"
|
|
99
|
+
: "Not configured"}/>
|
|
100
|
+
</div>
|
|
101
|
+
{settingsData.meilisearch.configured ? (<div className="text-muted-foreground text-xs">
|
|
102
|
+
<p>Host: {settingsData.meilisearch.host}</p>
|
|
103
|
+
<p>Index: {settingsData.meilisearch.indexUid}</p>
|
|
104
|
+
<p>Key: {settingsData.meilisearch.apiKey}</p>
|
|
105
|
+
</div>) : (<p className="text-muted-foreground text-xs">
|
|
106
|
+
Using local search engine. Configure{" "}
|
|
107
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
108
|
+
MEILISEARCH_HOST
|
|
109
|
+
</code>{" "}
|
|
110
|
+
and{" "}
|
|
111
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
112
|
+
MEILISEARCH_API_KEY
|
|
113
|
+
</code>{" "}
|
|
114
|
+
for dedicated search.
|
|
115
|
+
</p>)}
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex flex-col gap-2">
|
|
118
|
+
<div className="flex items-center justify-between">
|
|
119
|
+
<span className="text-muted-foreground text-sm">
|
|
120
|
+
AI Embeddings
|
|
121
|
+
</span>
|
|
122
|
+
<ConfigBadge configured={settingsData.embeddings.configured} label={settingsData.embeddings.configured
|
|
123
|
+
? settingsData.embeddings.provider === "openai"
|
|
124
|
+
? "OpenAI"
|
|
125
|
+
: "OpenRouter"
|
|
126
|
+
: "Not configured"}/>
|
|
127
|
+
</div>
|
|
128
|
+
{settingsData.embeddings.configured ? (<p className="text-muted-foreground text-xs">
|
|
129
|
+
Model: {settingsData.embeddings.model}
|
|
130
|
+
</p>) : (<p className="text-muted-foreground text-xs">
|
|
131
|
+
Semantic search disabled. Configure{" "}
|
|
132
|
+
<code className="rounded bg-muted px-1 text-[11px]">
|
|
133
|
+
OPENAI_API_KEY
|
|
134
|
+
</code>{" "}
|
|
135
|
+
for AI-powered results.
|
|
136
|
+
</p>)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>)}
|
|
140
|
+
|
|
141
|
+
{/* Stats overview */}
|
|
142
|
+
{analytics && (<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
143
|
+
<StatCard label="Total Searches" value={analytics.totalQueries.toLocaleString()}/>
|
|
144
|
+
<StatCard label="Unique Terms" value={analytics.uniqueTerms.toLocaleString()}/>
|
|
145
|
+
<StatCard label="Avg Results" value={analytics.avgResultCount}/>
|
|
146
|
+
<StatCard label="Zero Results" value={analytics.zeroResultCount.toLocaleString()}/>
|
|
147
|
+
<StatCard label="Zero Result Rate" value={`${analytics.zeroResultRate}%`}/>
|
|
148
|
+
<StatCard label="Indexed Items" value={analytics.indexedItems.toLocaleString()}/>
|
|
149
|
+
</div>)}
|
|
150
|
+
|
|
151
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
152
|
+
{/* Popular terms */}
|
|
153
|
+
<div className="rounded-lg border border-border bg-background">
|
|
154
|
+
<div className="border-border border-b px-5 py-3">
|
|
155
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
156
|
+
Popular Search Terms
|
|
157
|
+
</h3>
|
|
158
|
+
</div>
|
|
159
|
+
{popularTerms.length === 0 ? (<p className="px-5 py-6 text-center text-muted-foreground text-sm">
|
|
160
|
+
No search data yet.
|
|
161
|
+
</p>) : (<div className="divide-y divide-border">
|
|
162
|
+
{popularTerms.map((t) => (<div key={t.term} className="flex items-center justify-between px-5 py-2.5">
|
|
163
|
+
<span className="text-foreground text-sm">{t.term}</span>
|
|
164
|
+
<span className="text-muted-foreground text-xs">
|
|
165
|
+
{t.count} searches · {t.avgResultCount} avg results
|
|
166
|
+
</span>
|
|
167
|
+
</div>))}
|
|
168
|
+
</div>)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Zero result queries */}
|
|
172
|
+
<div className="rounded-lg border border-border bg-background">
|
|
173
|
+
<div className="border-border border-b px-5 py-3">
|
|
174
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
175
|
+
Zero-Result Queries
|
|
176
|
+
</h3>
|
|
177
|
+
</div>
|
|
178
|
+
{zeroResultTerms.length === 0 ? (<p className="px-5 py-6 text-center text-muted-foreground text-sm">
|
|
179
|
+
No zero-result queries yet.
|
|
180
|
+
</p>) : (<div className="divide-y divide-border">
|
|
181
|
+
{zeroResultTerms.map((t) => (<div key={t.term} className="flex items-center justify-between px-5 py-2.5">
|
|
182
|
+
<span className="text-foreground text-sm">{t.term}</span>
|
|
183
|
+
<span className="text-muted-foreground text-xs">
|
|
184
|
+
{t.count} times
|
|
185
|
+
</span>
|
|
186
|
+
</div>))}
|
|
187
|
+
</div>)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Synonyms management */}
|
|
192
|
+
<div className="rounded-lg border border-border bg-background">
|
|
193
|
+
<div className="border-border border-b px-5 py-3">
|
|
194
|
+
<h3 className="font-medium text-foreground text-sm">
|
|
195
|
+
Search Synonyms
|
|
196
|
+
</h3>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="p-5">
|
|
199
|
+
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
|
|
200
|
+
<input type="text" value={newTerm} onChange={(e) => setNewTerm(e.target.value)} placeholder="Term (e.g. tee)" className="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"/>
|
|
201
|
+
<input type="text" value={newSynonyms} onChange={(e) => setNewSynonyms(e.target.value)} placeholder="Synonyms, comma separated (e.g. t-shirt, shirt)" className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"/>
|
|
202
|
+
<button type="button" onClick={handleAddSynonym} disabled={addMutation.isPending} className="rounded-md bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50">
|
|
203
|
+
Add
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
{error && <p className="mb-3 text-destructive text-sm">{error}</p>}
|
|
207
|
+
{synonyms.length === 0 ? (<p className="py-4 text-center text-muted-foreground text-sm">
|
|
208
|
+
No synonyms configured yet.
|
|
209
|
+
</p>) : (<div className="divide-y divide-border rounded-md border border-border">
|
|
210
|
+
{synonyms.map((syn) => (<div key={syn.id} className="flex items-center justify-between px-4 py-2.5">
|
|
211
|
+
<div className="text-sm">
|
|
212
|
+
<span className="font-medium text-foreground">
|
|
213
|
+
{syn.term}
|
|
214
|
+
</span>
|
|
215
|
+
<span className="mx-2 text-muted-foreground">→</span>
|
|
216
|
+
<span className="text-muted-foreground">
|
|
217
|
+
{syn.synonyms.join(", ")}
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
<button type="button" onClick={() => removeMutation.mutate({
|
|
221
|
+
params: { id: syn.id },
|
|
222
|
+
})} className="text-muted-foreground text-xs hover:text-destructive">
|
|
223
|
+
Remove
|
|
224
|
+
</button>
|
|
225
|
+
</div>))}
|
|
226
|
+
</div>)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>);
|
|
230
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
export const analyticsEndpoint = createAdminEndpoint("/admin/search/analytics", { method: "GET" }, async (ctx) => {
|
|
3
|
+
const controller = ctx.context.controllers.search;
|
|
4
|
+
const [analytics, indexCount] = await Promise.all([
|
|
5
|
+
controller.getAnalytics(),
|
|
6
|
+
controller.getIndexCount(),
|
|
7
|
+
]);
|
|
8
|
+
return { analytics: { ...analytics, indexedItems: indexCount } };
|
|
9
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
export const bulkIndex = createAdminEndpoint("/admin/search/index/bulk", {
|
|
3
|
+
method: "POST",
|
|
4
|
+
body: z.object({
|
|
5
|
+
items: z
|
|
6
|
+
.array(z.object({
|
|
7
|
+
entityType: z.string().min(1).max(100),
|
|
8
|
+
entityId: z.string().min(1).max(200),
|
|
9
|
+
title: z.string().min(1).max(500),
|
|
10
|
+
body: z.string().max(10000).optional(),
|
|
11
|
+
tags: z.array(z.string().max(100)).max(50).optional(),
|
|
12
|
+
url: z.string().min(1).max(500),
|
|
13
|
+
image: z.string().max(500).optional(),
|
|
14
|
+
metadata: z
|
|
15
|
+
.record(z.string().max(100), z.unknown())
|
|
16
|
+
.refine((r) => Object.keys(r).length <= 50, "Too many keys")
|
|
17
|
+
.optional(),
|
|
18
|
+
}))
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(500),
|
|
21
|
+
}),
|
|
22
|
+
}, async (ctx) => {
|
|
23
|
+
const controller = ctx.context.controllers.search;
|
|
24
|
+
const result = await controller.bulkIndex(ctx.body.items);
|
|
25
|
+
return result;
|
|
26
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
export const clickAnalyticsEndpoint = createAdminEndpoint("/admin/search/clicks", { method: "GET" }, async (ctx) => {
|
|
3
|
+
const controller = ctx.context.controllers.search;
|
|
4
|
+
const analytics = await controller.getAnalytics();
|
|
5
|
+
return {
|
|
6
|
+
clickThroughRate: analytics.clickThroughRate,
|
|
7
|
+
avgClickPosition: analytics.avgClickPosition,
|
|
8
|
+
};
|
|
9
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createAdminEndpoint } from "@86d-app/core";
|
|
2
|
+
import { MeiliSearchProvider } from "../../meilisearch-provider";
|
|
3
|
+
function maskKey(key) {
|
|
4
|
+
if (key.length <= 8)
|
|
5
|
+
return `${key.slice(0, 2)}••••••`;
|
|
6
|
+
return `${key.slice(0, 4)}${"•".repeat(Math.min(key.length - 4, 20))}`;
|
|
7
|
+
}
|
|
8
|
+
async function verifyEmbeddingConnection(apiKey, baseUrl) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`${baseUrl}/models`, {
|
|
11
|
+
method: "GET",
|
|
12
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
13
|
+
});
|
|
14
|
+
if (res.ok) {
|
|
15
|
+
return { ok: true };
|
|
16
|
+
}
|
|
17
|
+
const text = await res.text().catch(() => "");
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
error: `HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: err instanceof Error ? err.message : "Connection failed",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export const getSettings = createAdminEndpoint("/admin/search/settings", { method: "GET" }, async (ctx) => {
|
|
31
|
+
const controller = ctx.context.controllers.search;
|
|
32
|
+
const options = ctx.context.options;
|
|
33
|
+
const meilisearchConfigured = Boolean(options?.meilisearchHost && options?.meilisearchApiKey);
|
|
34
|
+
const embeddingConfigured = Boolean(options?.openaiApiKey || options?.openrouterApiKey);
|
|
35
|
+
let meilisearchStatus = "not_configured";
|
|
36
|
+
let meilisearchError;
|
|
37
|
+
let documentCount;
|
|
38
|
+
if (meilisearchConfigured &&
|
|
39
|
+
options?.meilisearchHost &&
|
|
40
|
+
options.meilisearchApiKey) {
|
|
41
|
+
const provider = new MeiliSearchProvider(options.meilisearchHost, options.meilisearchApiKey, options.meilisearchIndexUid);
|
|
42
|
+
const healthy = await provider.isHealthy();
|
|
43
|
+
if (healthy) {
|
|
44
|
+
meilisearchStatus = "connected";
|
|
45
|
+
const stats = await provider.getStats();
|
|
46
|
+
if (stats) {
|
|
47
|
+
documentCount = stats.numberOfDocuments;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
meilisearchStatus = "error";
|
|
52
|
+
meilisearchError = "MeiliSearch instance is not reachable";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let embeddingStatus = "not_configured";
|
|
56
|
+
let embeddingError;
|
|
57
|
+
if (embeddingConfigured) {
|
|
58
|
+
const apiKey = options?.openaiApiKey ?? options?.openrouterApiKey ?? "";
|
|
59
|
+
const baseUrl = options?.openrouterApiKey
|
|
60
|
+
? "https://openrouter.ai/api/v1"
|
|
61
|
+
: "https://api.openai.com/v1";
|
|
62
|
+
const result = await verifyEmbeddingConnection(apiKey, baseUrl);
|
|
63
|
+
if (result.ok) {
|
|
64
|
+
embeddingStatus = "connected";
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
embeddingStatus = "error";
|
|
68
|
+
embeddingError = result.error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const indexCount = await controller.getIndexCount();
|
|
72
|
+
return {
|
|
73
|
+
meilisearch: {
|
|
74
|
+
status: meilisearchStatus,
|
|
75
|
+
error: meilisearchError,
|
|
76
|
+
configured: meilisearchConfigured,
|
|
77
|
+
host: options?.meilisearchHost ?? null,
|
|
78
|
+
apiKey: options?.meilisearchApiKey
|
|
79
|
+
? maskKey(options.meilisearchApiKey)
|
|
80
|
+
: null,
|
|
81
|
+
indexUid: options?.meilisearchIndexUid ?? "search",
|
|
82
|
+
documentCount,
|
|
83
|
+
},
|
|
84
|
+
embeddings: {
|
|
85
|
+
status: embeddingStatus,
|
|
86
|
+
error: embeddingError,
|
|
87
|
+
configured: embeddingConfigured,
|
|
88
|
+
provider: options?.openaiApiKey
|
|
89
|
+
? "openai"
|
|
90
|
+
: options?.openrouterApiKey
|
|
91
|
+
? "openrouter"
|
|
92
|
+
: null,
|
|
93
|
+
model: options?.embeddingModel ?? "text-embedding-3-small",
|
|
94
|
+
},
|
|
95
|
+
indexCount,
|
|
96
|
+
};
|
|
97
|
+
});
|