feat(api): return discovery category counts and banners for faceted search (#1686)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-16 22:39:21 +02:00
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');
+15 -2
View File
@@ -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>;