Files
fluxer/fluxer_api/src/api/guild/services/GuildDiscoveryService.ts
T

490 lines
17 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
import {
DISCOVERY_DEFAULT_LANGUAGE,
DISCOVERY_MAX_TAGS,
DiscoveryApplicationStatus,
type DiscoveryCategory,
isValidDiscoveryLanguage,
isValidDiscoveryTag,
normalizeDiscoveryTag,
} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures, getEffectiveGuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {DiscoveryAlreadyAppliedError} from '@fluxer/errors/src/domains/discovery/DiscoveryAlreadyAppliedError';
import {DiscoveryApplicationAlreadyReviewedError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationAlreadyReviewedError';
import {DiscoveryApplicationNotFoundError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationNotFoundError';
import {DiscoveryInsufficientMembersError} from '@fluxer/errors/src/domains/discovery/DiscoveryInsufficientMembersError';
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
import type {GuildSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes.jsx';
import type {DiscoveryApplicationPatchRequest} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
function sanitizeTags(tags: ReadonlyArray<string> | null | undefined): Array<string> {
if (!tags || tags.length === 0) return [];
const seen = new Set<string>();
const result: Array<string> = [];
for (const tag of tags) {
if (!isValidDiscoveryTag(tag)) continue;
const normalized = normalizeDiscoveryTag(tag);
if (seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
if (result.length >= DISCOVERY_MAX_TAGS) break;
}
return result;
}
function resolveLanguage(language: string | null | undefined, fallback: string | null = null): string {
if (language && isValidDiscoveryLanguage(language)) return language;
if (fallback && isValidDiscoveryLanguage(fallback)) return fallback;
return DISCOVERY_DEFAULT_LANGUAGE;
}
export abstract class IGuildDiscoveryService {
abstract apply(params: {
guildId: GuildID;
userId: UserID;
description: string;
categoryId: number;
primaryLanguage?: string;
customTags?: ReadonlyArray<string>;
}): Promise<GuildDiscoveryRow>;
abstract editApplication(params: {
guildId: GuildID;
userId: UserID;
data: DiscoveryApplicationPatchRequest;
}): Promise<GuildDiscoveryRow>;
abstract withdraw(params: {guildId: GuildID; userId: UserID}): Promise<void>;
abstract getStatus(guildId: GuildID): Promise<GuildDiscoveryRow | null>;
abstract approve(params: {guildId: GuildID; adminUserId: UserID; reason?: string}): Promise<GuildDiscoveryRow>;
abstract reject(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow>;
abstract remove(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow>;
abstract getEligibility(guildId: GuildID): Promise<{
eligible: boolean;
min_member_count: number;
}>;
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
abstract searchDiscoverable(params: {
query?: string;
categoryId?: number;
primaryLanguage?: string;
tag?: string;
sortBy?: string;
limit: number;
offset: number;
}): 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;
custom_tags: Array<string>;
member_count: number;
online_count: number;
features: Array<string>;
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,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly gatewayService: IGatewayService,
private readonly guildSearchService: IGuildSearchService | null,
) {
super();
}
async apply(params: {
guildId: GuildID;
userId: UserID;
description: string;
categoryId: number;
primaryLanguage?: string;
customTags?: ReadonlyArray<string>;
}): Promise<GuildDiscoveryRow> {
const {guildId, description, categoryId} = params;
const primaryLanguage = resolveLanguage(params.primaryLanguage);
const customTags = sanitizeTags(params.customTags);
contentModerationService.scanText(description, {
userId: params.userId,
guildId,
channelId: null,
messageId: null,
surface: 'profile_field',
});
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) {
throw new DiscoveryApplicationNotFoundError();
}
const {eligible} = await this.getEligibility(guildId);
if (!eligible) {
throw new DiscoveryInsufficientMembersError();
}
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (existing) {
if (
existing.status === DiscoveryApplicationStatus.PENDING ||
existing.status === DiscoveryApplicationStatus.APPROVED
) {
throw new DiscoveryAlreadyAppliedError();
}
}
const now = new Date();
const shouldAutoApprove = guild.features.has(GuildFeatures.VERIFIED) || guild.features.has(GuildFeatures.PARTNERED);
const row: GuildDiscoveryRow = {
guild_id: guildId,
status: shouldAutoApprove ? DiscoveryApplicationStatus.APPROVED : DiscoveryApplicationStatus.PENDING,
category_type: categoryId as DiscoveryCategory,
description,
primary_language: primaryLanguage,
custom_tags: customTags,
applied_at: now,
reviewed_at: shouldAutoApprove ? now : null,
reviewed_by: null,
review_reason: null,
removed_at: null,
removed_by: null,
removal_reason: null,
};
if (existing) {
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, row);
} else {
await this.discoveryRepository.upsert(row);
}
if (shouldAutoApprove) {
await this.addDiscoverableFeature(guildId);
if (this.guildSearchService) {
const updatedGuild = await this.guildRepository.findUnique(guildId);
if (updatedGuild) {
await this.guildSearchService.updateGuild(updatedGuild, {
description: row.description,
categoryId: row.category_type,
primaryLanguage: row.primary_language,
tags: row.custom_tags,
});
}
}
}
return row;
}
async editApplication(params: {
guildId: GuildID;
userId: UserID;
data: DiscoveryApplicationPatchRequest;
}): Promise<GuildDiscoveryRow> {
const {guildId, data} = params;
contentModerationService.scanText(data.description ?? null, {
userId: params.userId,
guildId,
channelId: null,
messageId: null,
surface: 'profile_field',
});
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (!existing) {
throw new DiscoveryApplicationNotFoundError();
}
if (
existing.status !== DiscoveryApplicationStatus.PENDING &&
existing.status !== DiscoveryApplicationStatus.APPROVED
) {
throw new DiscoveryApplicationAlreadyReviewedError();
}
const updatedRow: GuildDiscoveryRow = {
...existing,
description: data.description ?? existing.description,
category_type:
data.category_type !== undefined ? (data.category_type as DiscoveryCategory) : existing.category_type,
primary_language:
data.primary_language !== undefined
? resolveLanguage(data.primary_language, existing.primary_language ?? null)
: existing.primary_language,
custom_tags: data.custom_tags !== undefined ? sanitizeTags(data.custom_tags) : existing.custom_tags,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
if (existing.status === DiscoveryApplicationStatus.APPROVED && this.guildSearchService) {
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_type,
primaryLanguage: updatedRow.primary_language,
tags: updatedRow.custom_tags,
});
}
}
return updatedRow;
}
async withdraw(params: {guildId: GuildID; userId: UserID}): Promise<void> {
const {guildId} = params;
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (!existing) {
throw new DiscoveryApplicationNotFoundError();
}
await this.discoveryRepository.deleteByGuildId(guildId, existing.status, existing.applied_at);
if (existing.status === DiscoveryApplicationStatus.APPROVED) {
await this.removeDiscoverableFeature(guildId);
if (this.guildSearchService) {
await this.guildSearchService.deleteGuild(guildId);
}
}
}
async getStatus(guildId: GuildID): Promise<GuildDiscoveryRow | null> {
return this.discoveryRepository.findByGuildId(guildId);
}
async getEligibility(guildId: GuildID): Promise<{
eligible: boolean;
min_member_count: number;
}> {
const minMemberCount = Config.discovery.minMemberCount;
const guild = await this.guildRepository.findUnique(guildId);
const memberCount = guild?.memberCount ?? 0;
return {eligible: memberCount >= minMemberCount, min_member_count: minMemberCount};
}
async approve(params: {guildId: GuildID; adminUserId: UserID; reason?: string}): Promise<GuildDiscoveryRow> {
const {guildId, adminUserId, reason} = params;
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (!existing) {
throw new DiscoveryApplicationNotFoundError();
}
if (existing.status !== DiscoveryApplicationStatus.PENDING) {
throw new DiscoveryApplicationAlreadyReviewedError();
}
const now = new Date();
const updatedRow: GuildDiscoveryRow = {
...existing,
status: DiscoveryApplicationStatus.APPROVED,
reviewed_at: now,
reviewed_by: adminUserId,
review_reason: reason ?? null,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
await this.addDiscoverableFeature(guildId);
if (this.guildSearchService) {
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_type,
primaryLanguage: updatedRow.primary_language,
tags: updatedRow.custom_tags,
});
}
}
return updatedRow;
}
async reject(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow> {
const {guildId, adminUserId, reason} = params;
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (!existing) {
throw new DiscoveryApplicationNotFoundError();
}
if (existing.status !== DiscoveryApplicationStatus.PENDING) {
throw new DiscoveryApplicationAlreadyReviewedError();
}
const now = new Date();
const updatedRow: GuildDiscoveryRow = {
...existing,
status: DiscoveryApplicationStatus.REJECTED,
reviewed_at: now,
reviewed_by: adminUserId,
review_reason: reason,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
return updatedRow;
}
async remove(params: {guildId: GuildID; adminUserId: UserID; reason: string}): Promise<GuildDiscoveryRow> {
const {guildId, adminUserId, reason} = params;
const existing = await this.discoveryRepository.findByGuildId(guildId);
if (!existing) {
throw new DiscoveryApplicationNotFoundError();
}
if (existing.status !== DiscoveryApplicationStatus.APPROVED) {
throw new DiscoveryNotDiscoverableError();
}
const now = new Date();
const updatedRow: GuildDiscoveryRow = {
...existing,
status: DiscoveryApplicationStatus.REMOVED,
removed_at: now,
removed_by: adminUserId,
removal_reason: reason,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
await this.removeDiscoverableFeature(guildId);
if (this.guildSearchService) {
await this.guildSearchService.deleteGuild(guildId);
}
return updatedRow;
}
async listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>> {
return this.discoveryRepository.listFullByStatus(params.status);
}
async searchDiscoverable(params: {
query?: string;
categoryId?: number;
primaryLanguage?: string;
tag?: string;
sortBy?: string;
limit: number;
offset: number;
}): Promise<{
guilds: Array<DiscoveryGuildResult>;
total: number;
category_counts: Array<DiscoveryCategoryCount>;
}> {
if (!this.guildSearchService) {
throw new FeatureTemporarilyDisabledError();
}
const language =
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
const filters: GuildSearchFilters = {
isDiscoverable: true,
discoveryCategory: params.categoryId,
discoveryPrimaryLanguage: language,
discoveryTag: tag,
sortBy,
sortOrder: 'desc',
};
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,
custom_tags: hit.discoveryTags ?? [],
member_count: hit.memberCount,
online_count: 0,
features: hit.features,
verification_level: getEffectiveGuildVerificationLevel(
hit.verificationLevel,
hit.isDiscoverable,
Config.instance.phoneVerificationEnabled,
),
}));
const total = results.total;
if (guilds.length > 0) {
try {
const guildIds = guilds.map((g) => BigInt(g.id) as GuildID);
const freshCounts = await this.gatewayService.getDiscoveryGuildCounts(guildIds);
for (const guild of guilds) {
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
if (counts) {
guild.online_count = counts.onlineCount;
}
}
} catch (error) {
Logger.warn(
{error: error instanceof Error ? error.message : String(error)},
'[discovery] Failed to fetch fresh guild counts from gateway, using stale values',
);
}
}
return {guilds, total, category_counts: categoryCounts};
}
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) return;
const newFeatures = new Set(guild.features);
newFeatures.add(GuildFeatures.DISCOVERABLE);
const updatedGuild = await this.guildRepository.upsertPartial(guildId, {features: newFeatures}, guild.toRow());
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
}
private async removeDiscoverableFeature(guildId: GuildID): Promise<void> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) return;
const newFeatures = new Set(guild.features);
newFeatures.delete(GuildFeatures.DISCOVERABLE);
const updatedGuild = await this.guildRepository.upsertPartial(guildId, {features: newFeatures}, guild.toRow());
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
}
}