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

@@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService {
}): Promise<{
guilds: Array<DiscoveryGuildResult>;
total: number;
category_counts: Array<DiscoveryCategoryCount>;
}>;
}
interface DiscoveryCategoryCount {
category_type: number;
count: number;
}
interface DiscoveryGuildResult {
id: string;
name: string;
icon: string | null;
banner: string | null;
description: string | null;
category_type: number;
primary_language: string | null;
@@ -115,6 +122,25 @@ interface DiscoveryGuildResult {
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 {
constructor(
private readonly discoveryRepository: IGuildDiscoveryRepository,
@@ -377,6 +403,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
}): Promise<{
guilds: Array<DiscoveryGuildResult>;
total: number;
category_counts: Array<DiscoveryCategoryCount>;
}> {
if (!this.guildSearchService) {
throw new FeatureTemporarilyDisabledError();
@@ -393,14 +420,23 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
sortBy,
sortOrder: 'desc',
};
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
limit: params.limit,
offset: params.offset,
});
const categoryFacetFilters: GuildSearchFilters = {...filters, discoveryCategory: undefined};
const [results, categoryFacets] = await Promise.all([
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) => ({
id: hit.id,
name: hit.name,
icon: hit.iconHash,
banner: hit.bannerHash,
description: hit.discoveryDescription,
category_type: hit.discoveryCategory ?? 0,
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> {
@@ -120,6 +120,69 @@ describe('Discovery Search and Join', () => {
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
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 () => {
const owner = await createTestAccount(harness);
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"},
"name": {"type": "string", "description": "Guild name"},
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
"banner": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild banner hash"},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Discovery description"
@@ -29775,9 +29776,21 @@
},
"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": {
"type": "object",
@@ -2,6 +2,7 @@
import type {
ISearchAdapter as SchemaISearchAdapter,
SearchOptions as SchemaSearchOptions,
SearchResult as SchemaSearchResult,
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
@@ -18,9 +19,6 @@ export interface IGuildSearchService extends SchemaISearchAdapter<GuildSearchFil
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>>;
}
@@ -1,6 +1,9 @@
// 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 {
ElasticsearchGuildAdapter,
@@ -60,10 +63,7 @@ export class ElasticsearchGuildSearchService
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>> {
return this.search(query, filters, options);
}
@@ -23,6 +23,7 @@ interface MeilisearchSearchResponse<TResult> {
totalHits?: number;
limit?: number;
offset?: number;
facetDistribution?: Record<string, Record<string, number>>;
}
export class MeilisearchIndexAdapter<
@@ -139,6 +140,7 @@ export class MeilisearchIndexAdapter<
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 filter = joinMeiliFilters(this.buildFilters(filters));
const facets = options?.facets;
const result = await this.client.request<MeilisearchSearchResponse<TResult>>(
'POST',
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/search`,
@@ -150,11 +152,13 @@ export class MeilisearchIndexAdapter<
sort: this.buildSort?.(filters),
attributesToSearchOn: this.indexDefinition.searchableAttributes,
showRankingScore: false,
...(facets && facets.length > 0 ? {facets} : {}),
},
);
return {
hits: result.hits,
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
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 {
AuditLogSearchFilters,
GuildMemberSearchFilters,
@@ -171,10 +174,7 @@ export class MeilisearchGuildSearchService
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>> {
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);
})
.sort((left, right) => this.sortDocuments(left, right, filters, query));
const facets = options?.facets;
return {
hits: paginate(hits, options),
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 {
return value !== null;
}
@@ -452,7 +473,7 @@ class InMemoryGuildSearchService
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);
}
}