@86d-app/search 0.0.22 → 0.0.24

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.
Files changed (105) hide show
  1. package/dist/modules/search/src/__tests__/admin-settings.test.js +262 -0
  2. package/dist/modules/search/src/__tests__/controllers.test.js +853 -0
  3. package/dist/modules/search/src/__tests__/embedding-provider.test.js +150 -0
  4. package/dist/modules/search/src/__tests__/endpoint-security.test.js +250 -0
  5. package/dist/modules/search/src/__tests__/meilisearch-provider.test.js +318 -0
  6. package/dist/modules/search/src/__tests__/service-impl.test.js +703 -0
  7. package/dist/modules/search/src/__tests__/store-endpoints.test.js +295 -0
  8. package/dist/{admin/components/index.d.ts → modules/search/src/admin/components/index.jsx} +0 -1
  9. package/dist/modules/search/src/admin/components/search-analytics.jsx +230 -0
  10. package/dist/modules/search/src/admin/endpoints/analytics.js +9 -0
  11. package/dist/modules/search/src/admin/endpoints/bulk-index.js +26 -0
  12. package/dist/modules/search/src/admin/endpoints/click-analytics.js +9 -0
  13. package/dist/modules/search/src/admin/endpoints/get-settings.js +97 -0
  14. package/dist/modules/search/src/admin/endpoints/index-manage.js +32 -0
  15. package/dist/modules/search/src/admin/endpoints/index.js +21 -0
  16. package/dist/modules/search/src/admin/endpoints/popular.js +11 -0
  17. package/dist/modules/search/src/admin/endpoints/synonyms.js +30 -0
  18. package/dist/modules/search/src/admin/endpoints/zero-results.js +11 -0
  19. package/dist/modules/search/src/embedding-provider.js +77 -0
  20. package/dist/modules/search/src/index.js +75 -0
  21. package/dist/modules/search/src/meilisearch-provider.js +138 -0
  22. package/dist/modules/search/src/schema.js +61 -0
  23. package/dist/modules/search/src/service-impl.js +770 -0
  24. package/dist/modules/search/src/service.js +1 -0
  25. package/dist/modules/search/src/store/components/_hooks.js +10 -0
  26. package/dist/modules/search/src/store/components/index.jsx +9 -0
  27. package/dist/modules/search/src/store/components/search-bar.jsx +91 -0
  28. package/dist/modules/search/src/store/components/search-page.jsx +17 -0
  29. package/dist/modules/search/src/store/components/search-results.jsx +51 -0
  30. package/dist/modules/search/src/store/endpoints/click.js +15 -0
  31. package/dist/modules/search/src/store/endpoints/index.js +12 -0
  32. package/dist/modules/search/src/store/endpoints/recent.js +18 -0
  33. package/dist/modules/search/src/store/endpoints/search.js +57 -0
  34. package/dist/modules/search/src/store/endpoints/store-search.js +33 -0
  35. package/dist/modules/search/src/store/endpoints/suggest.js +12 -0
  36. package/package.json +1 -1
  37. package/src/__tests__/admin-settings.test.ts +367 -0
  38. package/src/__tests__/store-endpoints.test.ts +392 -0
  39. package/src/admin/endpoints/get-settings.ts +77 -0
  40. package/src/service.ts +20 -20
  41. package/dist/__tests__/controllers.test.d.ts +0 -2
  42. package/dist/__tests__/controllers.test.d.ts.map +0 -1
  43. package/dist/__tests__/embedding-provider.test.d.ts +0 -2
  44. package/dist/__tests__/embedding-provider.test.d.ts.map +0 -1
  45. package/dist/__tests__/endpoint-security.test.d.ts +0 -2
  46. package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
  47. package/dist/__tests__/meilisearch-provider.test.d.ts +0 -2
  48. package/dist/__tests__/meilisearch-provider.test.d.ts.map +0 -1
  49. package/dist/__tests__/service-impl.test.d.ts +0 -2
  50. package/dist/__tests__/service-impl.test.d.ts.map +0 -1
  51. package/dist/admin/components/index.d.ts.map +0 -1
  52. package/dist/admin/components/search-analytics.d.ts +0 -2
  53. package/dist/admin/components/search-analytics.d.ts.map +0 -1
  54. package/dist/admin/endpoints/analytics.d.ts +0 -15
  55. package/dist/admin/endpoints/analytics.d.ts.map +0 -1
  56. package/dist/admin/endpoints/bulk-index.d.ts +0 -20
  57. package/dist/admin/endpoints/bulk-index.d.ts.map +0 -1
  58. package/dist/admin/endpoints/click-analytics.d.ts +0 -7
  59. package/dist/admin/endpoints/click-analytics.d.ts.map +0 -1
  60. package/dist/admin/endpoints/get-settings.d.ts +0 -17
  61. package/dist/admin/endpoints/get-settings.d.ts.map +0 -1
  62. package/dist/admin/endpoints/index-manage.d.ts +0 -26
  63. package/dist/admin/endpoints/index-manage.d.ts.map +0 -1
  64. package/dist/admin/endpoints/index.d.ts +0 -125
  65. package/dist/admin/endpoints/index.d.ts.map +0 -1
  66. package/dist/admin/endpoints/popular.d.ts +0 -10
  67. package/dist/admin/endpoints/popular.d.ts.map +0 -1
  68. package/dist/admin/endpoints/synonyms.d.ts +0 -30
  69. package/dist/admin/endpoints/synonyms.d.ts.map +0 -1
  70. package/dist/admin/endpoints/zero-results.d.ts +0 -10
  71. package/dist/admin/endpoints/zero-results.d.ts.map +0 -1
  72. package/dist/embedding-provider.d.ts +0 -28
  73. package/dist/embedding-provider.d.ts.map +0 -1
  74. package/dist/index.d.ts +0 -23
  75. package/dist/index.d.ts.map +0 -1
  76. package/dist/meilisearch-provider.d.ts +0 -104
  77. package/dist/meilisearch-provider.d.ts.map +0 -1
  78. package/dist/schema.d.ts +0 -133
  79. package/dist/schema.d.ts.map +0 -1
  80. package/dist/service-impl.d.ts +0 -6
  81. package/dist/service-impl.d.ts.map +0 -1
  82. package/dist/service.d.ts +0 -127
  83. package/dist/service.d.ts.map +0 -1
  84. package/dist/store/components/_hooks.d.ts +0 -6
  85. package/dist/store/components/_hooks.d.ts.map +0 -1
  86. package/dist/store/components/index.d.ts +0 -10
  87. package/dist/store/components/index.d.ts.map +0 -1
  88. package/dist/store/components/search-bar.d.ts +0 -7
  89. package/dist/store/components/search-bar.d.ts.map +0 -1
  90. package/dist/store/components/search-page.d.ts +0 -4
  91. package/dist/store/components/search-page.d.ts.map +0 -1
  92. package/dist/store/components/search-results.d.ts +0 -9
  93. package/dist/store/components/search-results.d.ts.map +0 -1
  94. package/dist/store/endpoints/click.d.ts +0 -14
  95. package/dist/store/endpoints/click.d.ts.map +0 -1
  96. package/dist/store/endpoints/index.d.ts +0 -85
  97. package/dist/store/endpoints/index.d.ts.map +0 -1
  98. package/dist/store/endpoints/recent.d.ts +0 -15
  99. package/dist/store/endpoints/recent.d.ts.map +0 -1
  100. package/dist/store/endpoints/search.d.ts +0 -36
  101. package/dist/store/endpoints/search.d.ts.map +0 -1
  102. package/dist/store/endpoints/store-search.d.ts +0 -16
  103. package/dist/store/endpoints/store-search.d.ts.map +0 -1
  104. package/dist/store/endpoints/suggest.d.ts +0 -11
  105. 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
+ });
@@ -1,2 +1 @@
1
1
  export { SearchAnalytics } from "./search-analytics";
2
- //# sourceMappingURL=index.d.ts.map
@@ -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 &middot; {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">&rarr;</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
+ });