diff --git a/fluxer_api/pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter.ts b/fluxer_api/pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter.ts index 47b9823dc..79bae42d9 100644 --- a/fluxer_api/pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter.ts +++ b/fluxer_api/pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter.ts @@ -10,6 +10,7 @@ import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinition const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000; const DEEP_PAGINATION_BATCH_SIZE = 1000; const MAX_SEARCH_LIMIT = 1000; +const FACET_TERM_LIMIT = 200; interface ElasticsearchSearchHit { _source?: TResult; @@ -17,6 +18,10 @@ interface ElasticsearchSearchHit { sort?: SortResults; } +interface ElasticsearchTermsAggregation { + buckets?: Array<{key: string | number; doc_count: number}>; +} + interface ElasticsearchSearchResponse { hits: { total?: @@ -26,6 +31,7 @@ interface ElasticsearchSearchResponse { }; hits: Array>; }; + aggregations?: Record; } type ElasticsearchBaseSearchRequest = Omit; @@ -265,6 +271,7 @@ export class ElasticsearchIndexAdapter< }, ] : [{match_all: {}}]; + const facets = options?.facets; const searchParams: ElasticsearchBaseSearchRequest = { index: this.indexDefinition.indexName, query: { @@ -273,6 +280,11 @@ export class ElasticsearchIndexAdapter< 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 effectiveSort: NonNullable = @@ -357,13 +369,30 @@ export class ElasticsearchIndexAdapter< } private toSearchResult(result: ElasticsearchSearchResponse): SearchResult { + const facetCounts = this.toFacetCounts(result.aggregations); return { hits: this.mapHits(result.hits.hits), total: this.getTotalHits(result), cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)), + ...(facetCounts ? {facetCounts} : {}), }; } + private toFacetCounts( + aggregations: Record | undefined, + ): Record> | undefined { + if (!aggregations) { + return undefined; + } + const counts: Record> = {}; + 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>): Array { return hits.map((hit) => ({...hit._source!, id: hit._id!})); } diff --git a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts index 5822a0834..49cd0a5f4 100644 --- a/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts +++ b/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts @@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService { }): Promise<{ guilds: Array; total: number; + category_counts: Array; }>; } +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> | undefined, +): Array { + if (!counts) { + return []; + } + const entries: Array = []; + 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; total: number; + category_counts: Array; }> { 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 = 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 { diff --git a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts index cacab856d..9a674d0d2 100644 --- a/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts +++ b/fluxer_api/src/api/guild/tests/DiscoverySearchAndJoin.test.ts @@ -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(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(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'); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index a38772cf8..769ebf5a4 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -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", diff --git a/fluxer_api/src/api/search/IGuildSearchService.ts b/fluxer_api/src/api/search/IGuildSearchService.ts index f81011589..cdd2a8eec 100644 --- a/fluxer_api/src/api/search/IGuildSearchService.ts +++ b/fluxer_api/src/api/search/IGuildSearchService.ts @@ -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>; } diff --git a/fluxer_api/src/api/search/elasticsearch/ElasticsearchGuildSearchService.ts b/fluxer_api/src/api/search/elasticsearch/ElasticsearchGuildSearchService.ts index c114e20b8..2aaa85730 100644 --- a/fluxer_api/src/api/search/elasticsearch/ElasticsearchGuildSearchService.ts +++ b/fluxer_api/src/api/search/elasticsearch/ElasticsearchGuildSearchService.ts @@ -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> { return this.search(query, filters, options); } diff --git a/fluxer_api/src/api/search/meilisearch/MeilisearchIndexAdapter.ts b/fluxer_api/src/api/search/meilisearch/MeilisearchIndexAdapter.ts index 86eca913f..21771526a 100644 --- a/fluxer_api/src/api/search/meilisearch/MeilisearchIndexAdapter.ts +++ b/fluxer_api/src/api/search/meilisearch/MeilisearchIndexAdapter.ts @@ -23,6 +23,7 @@ interface MeilisearchSearchResponse { totalHits?: number; limit?: number; offset?: number; + facetDistribution?: Record>; } 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>( '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} : {}), }; } diff --git a/fluxer_api/src/api/search/meilisearch/MeilisearchSearchServices.ts b/fluxer_api/src/api/search/meilisearch/MeilisearchSearchServices.ts index f31809482..d42d8d65c 100644 --- a/fluxer_api/src/api/search/meilisearch/MeilisearchSearchServices.ts +++ b/fluxer_api/src/api/search/meilisearch/MeilisearchSearchServices.ts @@ -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> { return this.search(query, filters, options); } diff --git a/fluxer_api/src/api/test/search/InMemorySearchProvider.ts b/fluxer_api/src/api/test/search/InMemorySearchProvider.ts index 8b906d84e..f4861d75c 100644 --- a/fluxer_api/src/api/test/search/InMemorySearchProvider.ts +++ b/fluxer_api/src/api/test/search/InMemorySearchProvider.ts @@ -163,13 +163,34 @@ class InMemorySearchServiceBase 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(docs: Array, facets: Array): Record> { + const counts: Record> = {}; + for (const facet of facets) { + const facetCounts: Record = {}; + for (const doc of docs) { + const value = (doc as Record)[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); } } diff --git a/packages/schema/src/contracts/search/SearchAdapterTypes.ts b/packages/schema/src/contracts/search/SearchAdapterTypes.ts index c60b6cb60..4e70ed0aa 100644 --- a/packages/schema/src/contracts/search/SearchAdapterTypes.ts +++ b/packages/schema/src/contracts/search/SearchAdapterTypes.ts @@ -6,12 +6,16 @@ export interface SearchOptions { limit?: number; offset?: number; cursor?: Array; + facets?: Array; } +export type SearchFacetCounts = Readonly>>>; + export interface SearchResult { hits: Array; total: number; cursor?: Array; + facetCounts?: SearchFacetCounts; } export interface ISearchAdapter { diff --git a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts index 79c6a79a1..7f3a123dc 100644 --- a/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts +++ b/packages/schema/src/domains/guild/GuildDiscoverySchemas.ts @@ -80,6 +80,7 @@ const DiscoveryGuildResponse = z.object({ id: SnowflakeStringType.describe('Guild ID'), name: z.string().describe('Guild name'), icon: z.string().nullish().describe('Guild icon hash'), + banner: z.string().nullish().describe('Guild banner hash'), description: z.string().nullish().describe('Discovery description'), category_type: z.number().describe('Discovery category type'), primary_language: z.string().nullish().describe('Primary community language'), @@ -90,9 +91,17 @@ const DiscoveryGuildResponse = z.object({ 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({ guilds: z.array(DiscoveryGuildResponse).describe('Discovery guild results'), 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;