mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(api): return discovery category counts and banners for faceted search (#1686)
This commit is contained in:
1 parent
be69157811
commit
8a14281b87
11 files changed
+199
-22
No files matched your search
@@ -10,6 +10,7 @@ import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinition
|
|||||||
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
||||||
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
||||||
const MAX_SEARCH_LIMIT = 1000;
|
const MAX_SEARCH_LIMIT = 1000;
|
||||||
|
const FACET_TERM_LIMIT = 200;
|
||||||
|
|
||||||
interface ElasticsearchSearchHit<TResult> {
|
interface ElasticsearchSearchHit<TResult> {
|
||||||
_source?: TResult;
|
_source?: TResult;
|
||||||
@@ -17,6 +18,10 @@ interface ElasticsearchSearchHit<TResult> {
|
|||||||
sort?: SortResults;
|
sort?: SortResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ElasticsearchTermsAggregation {
|
||||||
|
buckets?: Array<{key: string | number; doc_count: number}>;
|
||||||
|
}
|
||||||
|
|
||||||
interface ElasticsearchSearchResponse<TResult> {
|
interface ElasticsearchSearchResponse<TResult> {
|
||||||
hits: {
|
hits: {
|
||||||
total?:
|
total?:
|
||||||
@@ -26,6 +31,7 @@ interface ElasticsearchSearchResponse<TResult> {
|
|||||||
};
|
};
|
||||||
hits: Array<ElasticsearchSearchHit<TResult>>;
|
hits: Array<ElasticsearchSearchHit<TResult>>;
|
||||||
};
|
};
|
||||||
|
aggregations?: Record<string, ElasticsearchTermsAggregation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElasticsearchBaseSearchRequest = Omit<SearchRequest, 'from' | 'search_after' | 'size' | 'track_total_hits'>;
|
type ElasticsearchBaseSearchRequest = Omit<SearchRequest, 'from' | 'search_after' | 'size' | 'track_total_hits'>;
|
||||||
@@ -265,6 +271,7 @@ export class ElasticsearchIndexAdapter<
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [{match_all: {}}];
|
: [{match_all: {}}];
|
||||||
|
const facets = options?.facets;
|
||||||
const searchParams: ElasticsearchBaseSearchRequest = {
|
const searchParams: ElasticsearchBaseSearchRequest = {
|
||||||
index: this.indexDefinition.indexName,
|
index: this.indexDefinition.indexName,
|
||||||
query: {
|
query: {
|
||||||
@@ -273,6 +280,11 @@ export class ElasticsearchIndexAdapter<
|
|||||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(facets && facets.length > 0
|
||||||
|
? {
|
||||||
|
aggs: Object.fromEntries(facets.map((facet) => [facet, {terms: {field: facet, size: FACET_TERM_LIMIT}}])),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const defaultSort: SortCombinations = {id: {order: 'desc'}};
|
const defaultSort: SortCombinations = {id: {order: 'desc'}};
|
||||||
const effectiveSort: NonNullable<SearchRequest['sort']> =
|
const effectiveSort: NonNullable<SearchRequest['sort']> =
|
||||||
@@ -357,13 +369,30 @@ export class ElasticsearchIndexAdapter<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toSearchResult(result: ElasticsearchSearchResponse<TResult>): SearchResult<TResult> {
|
private toSearchResult(result: ElasticsearchSearchResponse<TResult>): SearchResult<TResult> {
|
||||||
|
const facetCounts = this.toFacetCounts(result.aggregations);
|
||||||
return {
|
return {
|
||||||
hits: this.mapHits(result.hits.hits),
|
hits: this.mapHits(result.hits.hits),
|
||||||
total: this.getTotalHits(result),
|
total: this.getTotalHits(result),
|
||||||
cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)),
|
cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)),
|
||||||
|
...(facetCounts ? {facetCounts} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toFacetCounts(
|
||||||
|
aggregations: Record<string, ElasticsearchTermsAggregation> | undefined,
|
||||||
|
): Record<string, Record<string, number>> | undefined {
|
||||||
|
if (!aggregations) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const counts: Record<string, Record<string, number>> = {};
|
||||||
|
for (const [facet, aggregation] of Object.entries(aggregations)) {
|
||||||
|
counts[facet] = Object.fromEntries(
|
||||||
|
(aggregation.buckets ?? []).map((bucket) => [String(bucket.key), bucket.doc_count]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
private mapHits(hits: Array<ElasticsearchSearchHit<TResult>>): Array<TResult> {
|
private mapHits(hits: Array<ElasticsearchSearchHit<TResult>>): Array<TResult> {
|
||||||
return hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
return hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
guilds: Array<DiscoveryGuildResult>;
|
guilds: Array<DiscoveryGuildResult>;
|
||||||
total: number;
|
total: number;
|
||||||
|
category_counts: Array<DiscoveryCategoryCount>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DiscoveryCategoryCount {
|
||||||
|
category_type: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DiscoveryGuildResult {
|
interface DiscoveryGuildResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
banner: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
category_type: number;
|
category_type: number;
|
||||||
primary_language: string | null;
|
primary_language: string | null;
|
||||||
@@ -115,6 +122,25 @@ interface DiscoveryGuildResult {
|
|||||||
verification_level: number;
|
verification_level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||||
|
|
||||||
|
function toDiscoveryCategoryCounts(
|
||||||
|
counts: Readonly<Record<string, number>> | undefined,
|
||||||
|
): Array<DiscoveryCategoryCount> {
|
||||||
|
if (!counts) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries: Array<DiscoveryCategoryCount> = [];
|
||||||
|
for (const [key, count] of Object.entries(counts)) {
|
||||||
|
const categoryType = Number.parseInt(key, 10);
|
||||||
|
if (!Number.isInteger(categoryType) || count <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push({category_type: categoryType, count});
|
||||||
|
}
|
||||||
|
return entries.sort((left, right) => left.category_type - right.category_type);
|
||||||
|
}
|
||||||
|
|
||||||
export class GuildDiscoveryService extends IGuildDiscoveryService {
|
export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||||
@@ -377,6 +403,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
guilds: Array<DiscoveryGuildResult>;
|
guilds: Array<DiscoveryGuildResult>;
|
||||||
total: number;
|
total: number;
|
||||||
|
category_counts: Array<DiscoveryCategoryCount>;
|
||||||
}> {
|
}> {
|
||||||
if (!this.guildSearchService) {
|
if (!this.guildSearchService) {
|
||||||
throw new FeatureTemporarilyDisabledError();
|
throw new FeatureTemporarilyDisabledError();
|
||||||
@@ -393,14 +420,23 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
};
|
};
|
||||||
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
const categoryFacetFilters: GuildSearchFilters = {...filters, discoveryCategory: undefined};
|
||||||
limit: params.limit,
|
const [results, categoryFacets] = await Promise.all([
|
||||||
offset: params.offset,
|
this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||||
});
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
}),
|
||||||
|
this.guildSearchService.searchGuilds(params.query ?? '', categoryFacetFilters, {
|
||||||
|
limit: 0,
|
||||||
|
facets: [DISCOVERY_CATEGORY_FACET],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const categoryCounts = toDiscoveryCategoryCounts(categoryFacets.facetCounts?.[DISCOVERY_CATEGORY_FACET]);
|
||||||
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
|
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
name: hit.name,
|
name: hit.name,
|
||||||
icon: hit.iconHash,
|
icon: hit.iconHash,
|
||||||
|
banner: hit.bannerHash,
|
||||||
description: hit.discoveryDescription,
|
description: hit.discoveryDescription,
|
||||||
category_type: hit.discoveryCategory ?? 0,
|
category_type: hit.discoveryCategory ?? 0,
|
||||||
primary_language: hit.discoveryPrimaryLanguage ?? null,
|
primary_language: hit.discoveryPrimaryLanguage ?? null,
|
||||||
@@ -429,7 +465,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {guilds, total};
|
return {guilds, total, category_counts: categoryCounts};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
|
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
|
||||||
|
|||||||
@@ -120,6 +120,69 @@ describe('Discovery Search and Join', () => {
|
|||||||
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
|
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
|
||||||
expect(found!.verification_level).toBe(GuildVerificationLevel.LOW);
|
expect(found!.verification_level).toBe(GuildVerificationLevel.LOW);
|
||||||
});
|
});
|
||||||
|
test('should report category counts that ignore the selected category filter', async () => {
|
||||||
|
const admin = await createTestAccount(harness);
|
||||||
|
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||||
|
const gamingOwner = await createTestAccount(harness);
|
||||||
|
const gamingGuild = await createGuild(harness, gamingOwner.token, 'Counted Gaming Guild');
|
||||||
|
await setGuildMemberCount(harness, gamingGuild.id, 10);
|
||||||
|
await applyAndApprove(
|
||||||
|
harness,
|
||||||
|
gamingOwner.token,
|
||||||
|
admin.token,
|
||||||
|
gamingGuild.id,
|
||||||
|
'A gaming community for counting',
|
||||||
|
DiscoveryCategories.GAMING,
|
||||||
|
);
|
||||||
|
const musicOwner = await createTestAccount(harness);
|
||||||
|
const musicGuild = await createGuild(harness, musicOwner.token, 'Counted Music Guild');
|
||||||
|
await setGuildMemberCount(harness, musicGuild.id, 10);
|
||||||
|
await applyAndApprove(
|
||||||
|
harness,
|
||||||
|
musicOwner.token,
|
||||||
|
admin.token,
|
||||||
|
musicGuild.id,
|
||||||
|
'A music community for counting',
|
||||||
|
DiscoveryCategories.MUSIC,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searcher = await createTestAccount(harness);
|
||||||
|
const filtered = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||||
|
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(filtered.guilds.every((guild) => guild.category_type === DiscoveryCategories.GAMING)).toBe(true);
|
||||||
|
const gamingCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.GAMING);
|
||||||
|
const musicCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.MUSIC);
|
||||||
|
expect(gamingCount?.count).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(musicCount?.count).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should expose the guild banner hash in search results', async () => {
|
||||||
|
const owner = await createTestAccount(harness);
|
||||||
|
const guild = await createGuild(harness, owner.token, 'Bannered Guild');
|
||||||
|
await setGuildMemberCount(harness, guild.id, 10);
|
||||||
|
const admin = await createTestAccount(harness);
|
||||||
|
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||||
|
await applyAndApprove(
|
||||||
|
harness,
|
||||||
|
owner.token,
|
||||||
|
admin.token,
|
||||||
|
guild.id,
|
||||||
|
'A community with a banner',
|
||||||
|
DiscoveryCategories.GAMING,
|
||||||
|
);
|
||||||
|
const searcher = await createTestAccount(harness);
|
||||||
|
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||||
|
.get('/discovery/guilds')
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
const found = results.guilds.find((entry) => entry.id === guild.id);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found).toHaveProperty('banner');
|
||||||
|
});
|
||||||
|
|
||||||
test('should not return pending guilds in search results', async () => {
|
test('should not return pending guilds in search results', async () => {
|
||||||
const owner = await createTestAccount(harness);
|
const owner = await createTestAccount(harness);
|
||||||
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
||||||
|
|||||||
@@ -29747,6 +29747,7 @@
|
|||||||
"id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"},
|
"id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"},
|
||||||
"name": {"type": "string", "description": "Guild name"},
|
"name": {"type": "string", "description": "Guild name"},
|
||||||
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
|
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
|
||||||
|
"banner": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild banner hash"},
|
||||||
"description": {
|
"description": {
|
||||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
"description": "Discovery description"
|
"description": "Discovery description"
|
||||||
@@ -29775,9 +29776,21 @@
|
|||||||
},
|
},
|
||||||
"description": "Discovery guild results"
|
"description": "Discovery guild results"
|
||||||
},
|
},
|
||||||
"total": {"type": "number", "description": "Total number of matching guilds"}
|
"total": {"type": "number", "description": "Total number of matching guilds"},
|
||||||
|
"category_counts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"category_type": {"type": "number", "description": "Discovery category type"},
|
||||||
|
"count": {"type": "number", "description": "Number of matching guilds in this category"}
|
||||||
|
},
|
||||||
|
"required": ["category_type", "count"]
|
||||||
|
},
|
||||||
|
"description": "Match counts per category for the current filters, ignoring the category filter"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["guilds", "total"]
|
"required": ["guilds", "total", "category_counts"]
|
||||||
},
|
},
|
||||||
"DonationCheckoutResponse": {
|
"DonationCheckoutResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ISearchAdapter as SchemaISearchAdapter,
|
ISearchAdapter as SchemaISearchAdapter,
|
||||||
|
SearchOptions as SchemaSearchOptions,
|
||||||
SearchResult as SchemaSearchResult,
|
SearchResult as SchemaSearchResult,
|
||||||
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||||
@@ -18,9 +19,6 @@ export interface IGuildSearchService extends SchemaISearchAdapter<GuildSearchFil
|
|||||||
searchGuilds(
|
searchGuilds(
|
||||||
query: string,
|
query: string,
|
||||||
filters: GuildSearchFilters,
|
filters: GuildSearchFilters,
|
||||||
options?: {
|
options?: SchemaSearchOptions,
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
},
|
|
||||||
): Promise<SchemaSearchResult<SearchableGuild>>;
|
): Promise<SchemaSearchResult<SearchableGuild>>;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
import type {
|
||||||
|
SearchOptions as SchemaSearchOptions,
|
||||||
|
SearchResult as SchemaSearchResult,
|
||||||
|
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||||
import {
|
import {
|
||||||
ElasticsearchGuildAdapter,
|
ElasticsearchGuildAdapter,
|
||||||
@@ -60,10 +63,7 @@ export class ElasticsearchGuildSearchService
|
|||||||
searchGuilds(
|
searchGuilds(
|
||||||
query: string,
|
query: string,
|
||||||
filters: GuildSearchFilters,
|
filters: GuildSearchFilters,
|
||||||
options?: {
|
options?: SchemaSearchOptions,
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
},
|
|
||||||
): Promise<SchemaSearchResult<SearchableGuild>> {
|
): Promise<SchemaSearchResult<SearchableGuild>> {
|
||||||
return this.search(query, filters, options);
|
return this.search(query, filters, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface MeilisearchSearchResponse<TResult> {
|
|||||||
totalHits?: number;
|
totalHits?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
facetDistribution?: Record<string, Record<string, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MeilisearchIndexAdapter<
|
export class MeilisearchIndexAdapter<
|
||||||
@@ -139,6 +140,7 @@ export class MeilisearchIndexAdapter<
|
|||||||
const limit = Math.min(Math.max(requestedLimit, 0), MAX_SEARCH_LIMIT);
|
const limit = Math.min(Math.max(requestedLimit, 0), MAX_SEARCH_LIMIT);
|
||||||
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
||||||
const filter = joinMeiliFilters(this.buildFilters(filters));
|
const filter = joinMeiliFilters(this.buildFilters(filters));
|
||||||
|
const facets = options?.facets;
|
||||||
const result = await this.client.request<MeilisearchSearchResponse<TResult>>(
|
const result = await this.client.request<MeilisearchSearchResponse<TResult>>(
|
||||||
'POST',
|
'POST',
|
||||||
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/search`,
|
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/search`,
|
||||||
@@ -150,11 +152,13 @@ export class MeilisearchIndexAdapter<
|
|||||||
sort: this.buildSort?.(filters),
|
sort: this.buildSort?.(filters),
|
||||||
attributesToSearchOn: this.indexDefinition.searchableAttributes,
|
attributesToSearchOn: this.indexDefinition.searchableAttributes,
|
||||||
showRankingScore: false,
|
showRankingScore: false,
|
||||||
|
...(facets && facets.length > 0 ? {facets} : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
hits: result.hits,
|
hits: result.hits,
|
||||||
total: result.totalHits ?? result.estimatedTotalHits ?? result.hits.length,
|
total: result.totalHits ?? result.estimatedTotalHits ?? result.hits.length,
|
||||||
|
...(result.facetDistribution ? {facetCounts: result.facetDistribution} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
import type {
|
||||||
|
SearchOptions as SchemaSearchOptions,
|
||||||
|
SearchResult as SchemaSearchResult,
|
||||||
|
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||||
import type {
|
import type {
|
||||||
AuditLogSearchFilters,
|
AuditLogSearchFilters,
|
||||||
GuildMemberSearchFilters,
|
GuildMemberSearchFilters,
|
||||||
@@ -171,10 +174,7 @@ export class MeilisearchGuildSearchService
|
|||||||
searchGuilds(
|
searchGuilds(
|
||||||
query: string,
|
query: string,
|
||||||
filters: GuildSearchFilters,
|
filters: GuildSearchFilters,
|
||||||
options?: {
|
options?: SchemaSearchOptions,
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
},
|
|
||||||
): Promise<SchemaSearchResult<SearchableGuild>> {
|
): Promise<SchemaSearchResult<SearchableGuild>> {
|
||||||
return this.search(query, filters, options);
|
return this.search(query, filters, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,13 +163,34 @@ class InMemorySearchServiceBase<TFilters, TDocument extends SearchableDocument>
|
|||||||
return trimmed.length === 0 || stringArrayContainsText(this.collectText(doc).filter(isString), trimmed);
|
return trimmed.length === 0 || stringArrayContainsText(this.collectText(doc).filter(isString), trimmed);
|
||||||
})
|
})
|
||||||
.sort((left, right) => this.sortDocuments(left, right, filters, query));
|
.sort((left, right) => this.sortDocuments(left, right, filters, query));
|
||||||
|
const facets = options?.facets;
|
||||||
return {
|
return {
|
||||||
hits: paginate(hits, options),
|
hits: paginate(hits, options),
|
||||||
total: hits.length,
|
total: hits.length,
|
||||||
|
...(facets && facets.length > 0 ? {facetCounts: countFacets(hits, facets)} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countFacets<TDocument>(docs: Array<TDocument>, facets: Array<string>): Record<string, Record<string, number>> {
|
||||||
|
const counts: Record<string, Record<string, number>> = {};
|
||||||
|
for (const facet of facets) {
|
||||||
|
const facetCounts: Record<string, number> = {};
|
||||||
|
for (const doc of docs) {
|
||||||
|
const value = (doc as Record<string, unknown>)[facet];
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of Array.isArray(value) ? value : [value]) {
|
||||||
|
const key = String(entry);
|
||||||
|
facetCounts[key] = (facetCounts[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts[facet] = facetCounts;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
function isString(value: string | null): value is string {
|
function isString(value: string | null): value is string {
|
||||||
return value !== null;
|
return value !== null;
|
||||||
}
|
}
|
||||||
@@ -452,7 +473,7 @@ class InMemoryGuildSearchService
|
|||||||
await this.deleteDocuments(guildIds.map((id) => id.toString()));
|
await this.deleteDocuments(guildIds.map((id) => id.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchGuilds(query: string, filters: GuildSearchFilters, options?: {limit?: number; offset?: number}) {
|
searchGuilds(query: string, filters: GuildSearchFilters, options?: SearchOptions) {
|
||||||
return this.search(query, filters, options);
|
return this.search(query, filters, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ export interface SearchOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
cursor?: Array<string>;
|
cursor?: Array<string>;
|
||||||
|
facets?: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchFacetCounts = Readonly<Record<string, Readonly<Record<string, number>>>>;
|
||||||
|
|
||||||
export interface SearchResult<TResult> {
|
export interface SearchResult<TResult> {
|
||||||
hits: Array<TResult>;
|
hits: Array<TResult>;
|
||||||
total: number;
|
total: number;
|
||||||
cursor?: Array<string>;
|
cursor?: Array<string>;
|
||||||
|
facetCounts?: SearchFacetCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchAdapter<TFilters, TResult> {
|
export interface ISearchAdapter<TFilters, TResult> {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const DiscoveryGuildResponse = z.object({
|
|||||||
id: SnowflakeStringType.describe('Guild ID'),
|
id: SnowflakeStringType.describe('Guild ID'),
|
||||||
name: z.string().describe('Guild name'),
|
name: z.string().describe('Guild name'),
|
||||||
icon: z.string().nullish().describe('Guild icon hash'),
|
icon: z.string().nullish().describe('Guild icon hash'),
|
||||||
|
banner: z.string().nullish().describe('Guild banner hash'),
|
||||||
description: z.string().nullish().describe('Discovery description'),
|
description: z.string().nullish().describe('Discovery description'),
|
||||||
category_type: z.number().describe('Discovery category type'),
|
category_type: z.number().describe('Discovery category type'),
|
||||||
primary_language: z.string().nullish().describe('Primary community language'),
|
primary_language: z.string().nullish().describe('Primary community language'),
|
||||||
@@ -90,9 +91,17 @@ const DiscoveryGuildResponse = z.object({
|
|||||||
verification_level: z.number().describe('Verification level'),
|
verification_level: z.number().describe('Verification level'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DiscoveryCategoryCountResponse = z.object({
|
||||||
|
category_type: z.number().describe('Discovery category type'),
|
||||||
|
count: z.number().describe('Number of matching guilds in this category'),
|
||||||
|
});
|
||||||
|
|
||||||
export const DiscoveryGuildListResponse = z.object({
|
export const DiscoveryGuildListResponse = z.object({
|
||||||
guilds: z.array(DiscoveryGuildResponse).describe('Discovery guild results'),
|
guilds: z.array(DiscoveryGuildResponse).describe('Discovery guild results'),
|
||||||
total: z.number().describe('Total number of matching guilds'),
|
total: z.number().describe('Total number of matching guilds'),
|
||||||
|
category_counts: z
|
||||||
|
.array(DiscoveryCategoryCountResponse)
|
||||||
|
.describe('Match counts per category for the current filters, ignoring the category filter'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DiscoveryGuildListResponse = z.infer<typeof DiscoveryGuildListResponse>;
|
export type DiscoveryGuildListResponse = z.infer<typeof DiscoveryGuildListResponse>;
|
||||||
|
|||||||
Reference in new issue
Block a user