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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user