feat(discovery): more work on discovery plus a few fixes

This commit is contained in:
Hampus Kraft committed 2026-02-17 15:41:08 +00:00
1 parent b19e9fb243
commit 302c0d2a0c
137 files changed
+7116 -2047

No files matched your search

@@ -21,6 +21,7 @@ import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
import {GuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
import {GuildOperationsService} from '@fluxer/api/src/guild/services/data/GuildOperationsService';
@@ -79,6 +80,7 @@ export class GuildDataService {
this.webhookRepository,
this.helpers,
this.limitConfigService,
new GuildDiscoveryRepository(),
this.guildManagedTraitService,
);
@@ -26,8 +26,6 @@ import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
import {
DISCOVERY_MIN_MEMBER_COUNT,
DISCOVERY_MIN_MEMBER_COUNT_DEV,
DiscoveryApplicationStatus,
DiscoveryCategories,
type DiscoveryCategory,
@@ -42,7 +40,7 @@ import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discover
import type {GuildSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {DiscoveryApplicationPatchRequest} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
const VALID_CATEGORY_IDS = new Set<number>(Object.values(DiscoveryCategories));
const VALID_CATEGORY_TYPES = new Set<number>(Object.values(DiscoveryCategories));
export abstract class IGuildDiscoveryService {
abstract apply(params: {
@@ -68,6 +66,8 @@ export abstract class IGuildDiscoveryService {
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; limit: number}): Promise<Array<GuildDiscoveryRow>>;
abstract searchDiscoverable(params: {
@@ -84,7 +84,7 @@ export interface DiscoveryGuildResult {
name: string;
icon: string | null;
description: string | null;
category_id: number;
category_type: number;
member_count: number;
online_count: number;
features: Array<string>;
@@ -109,7 +109,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
}): Promise<GuildDiscoveryRow> {
const {guildId, description, categoryId} = params;
if (!VALID_CATEGORY_IDS.has(categoryId)) {
if (!VALID_CATEGORY_TYPES.has(categoryId)) {
throw new DiscoveryInvalidCategoryError();
}
@@ -118,8 +118,8 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
throw new DiscoveryApplicationNotFoundError();
}
const minMembers = Config.dev.testModeEnabled ? DISCOVERY_MIN_MEMBER_COUNT_DEV : DISCOVERY_MIN_MEMBER_COUNT;
if (guild.memberCount < minMembers) {
const {eligible} = await this.getEligibility(guildId);
if (!eligible) {
throw new DiscoveryInsufficientMembersError();
}
@@ -137,7 +137,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
const row: GuildDiscoveryRow = {
guild_id: guildId,
status: DiscoveryApplicationStatus.PENDING,
category_id: categoryId as DiscoveryCategory,
category_type: categoryId as DiscoveryCategory,
description,
applied_at: now,
reviewed_at: null,
@@ -175,14 +175,15 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
throw new DiscoveryApplicationAlreadyReviewedError();
}
if (data.category_id !== undefined && !VALID_CATEGORY_IDS.has(data.category_id)) {
if (data.category_type !== undefined && !VALID_CATEGORY_TYPES.has(data.category_type)) {
throw new DiscoveryInvalidCategoryError();
}
const updatedRow: GuildDiscoveryRow = {
...existing,
description: data.description ?? existing.description,
category_id: data.category_id !== undefined ? (data.category_id as DiscoveryCategory) : existing.category_id,
category_type:
data.category_type !== undefined ? (data.category_type as DiscoveryCategory) : existing.category_type,
};
await this.discoveryRepository.updateStatus(guildId, existing.status, existing.applied_at, updatedRow);
@@ -192,7 +193,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_id,
categoryId: updatedRow.category_type,
});
}
}
@@ -222,6 +223,13 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
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;
@@ -251,7 +259,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
if (guild) {
await this.guildSearchService.updateGuild(guild, {
description: updatedRow.description,
categoryId: updatedRow.category_id,
categoryId: updatedRow.category_type,
});
}
}
@@ -352,7 +360,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
name: hit.name,
icon: hit.iconHash,
description: hit.discoveryDescription,
category_id: hit.discoveryCategory ?? 0,
category_type: hit.discoveryCategory ?? 0,
member_count: hit.memberCount,
online_count: hit.onlineCount,
features: hit.features,
@@ -374,7 +382,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
const discoveryRow = await this.discoveryRepository.findByGuildId(statusRow.guild_id);
if (!discoveryRow) continue;
if (params.categoryId !== undefined && discoveryRow.category_id !== params.categoryId) {
if (params.categoryId !== undefined && discoveryRow.category_type !== params.categoryId) {
continue;
}
@@ -386,7 +394,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
name: guild.name,
icon: guild.iconHash,
description: discoveryRow.description,
category_id: discoveryRow.category_id,
category_type: discoveryRow.category_type,
member_count: guild.memberCount,
online_count: 0,
features: Array.from(guild.features),
@@ -51,11 +51,6 @@ import type {
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
interface RoleReorderOperation {
roleId: RoleID;
precedingRoleId: RoleID | null;
}
interface GuildAuth {
guildData: GuildResponse;
checkPermission: (permission: bigint) => Promise<void>;
@@ -580,15 +575,15 @@ export class GuildRoleService {
userId: UserID;
guildId: GuildID;
updates: Array<{roleId: RoleID; position?: number}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const everyoneRoleId = guildIdToRoleId(guildId);
for (const update of updates) {
if (update.roleId === guildIdToRoleId(guildId)) {
if (update.roleId === everyoneRoleId) {
throw InputValidationError.fromCode('id', ValidationErrorCodes.CANNOT_REORDER_EVERYONE_ROLE);
}
if (!roleMap.has(update.roleId)) {
@@ -598,7 +593,6 @@ export class GuildRoleService {
}
}
const everyoneRoleId = guildIdToRoleId(guildId);
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
@@ -642,137 +636,18 @@ export class GuildRoleService {
return 0;
});
for (const role of currentOrder) {
for (let i = 0; i < currentOrder.length; i++) {
const role = currentOrder[i]!;
if (!canManageRole(role)) {
const originalIndex = currentOrder.findIndex((r) => r.id === role.id);
const newIndex = targetOrder.findIndex((r) => r.id === role.id);
if (originalIndex !== newIndex) {
if (i !== newIndex) {
throw new MissingPermissionsError();
}
}
}
const reorderedIds = targetOrder.map((r) => r.id);
await this.updateRolePositionsLocked({
userId,
guildId,
operation: {roleId: reorderedIds[0]!, precedingRoleId: null},
customOrder: reorderedIds,
});
}
private async updateRolePositionsLocked(params: {
userId: UserID;
guildId: GuildID;
operation: RoleReorderOperation;
customOrder?: Array<RoleID>;
}): Promise<void> {
const {userId, guildId, operation, customOrder} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const targetRole = roleMap.get(operation.roleId);
if (!targetRole) {
throw InputValidationError.fromCode('role_id', ValidationErrorCodes.INVALID_ROLE_ID, {
roleId: operation.roleId.toString(),
});
}
const everyoneRoleId = guildIdToRoleId(guildId);
if (targetRole.id === everyoneRoleId) {
throw InputValidationError.fromCode('role_id', ValidationErrorCodes.CANNOT_REORDER_EVERYONE_ROLE);
}
let precedingRole: GuildRole | null = null;
if (!customOrder) {
if (operation.precedingRoleId) {
if (operation.precedingRoleId === targetRole.id) {
throw InputValidationError.fromCode(
'preceding_role_id',
ValidationErrorCodes.CANNOT_USE_SAME_ROLE_AS_PRECEDING,
);
}
precedingRole = roleMap.get(operation.precedingRoleId) ?? null;
if (!precedingRole) {
throw InputValidationError.fromCode('preceding_role_id', ValidationErrorCodes.INVALID_ROLE_ID, {
roleId: operation.precedingRoleId.toString(),
});
}
}
}
const sortedRoles = [...allRoles].sort((a, b) => {
if (b.position !== a.position) {
return b.position - a.position;
}
return String(a.id).localeCompare(String(b.id));
});
const originalIndex = sortedRoles.findIndex((role) => role.id === targetRole.id);
if (originalIndex === -1) {
throw new Error('Role ordering inconsistency detected');
}
const baseList = sortedRoles.filter((role) => role.id !== targetRole.id);
let insertIndex = 0;
if (!customOrder) {
if (precedingRole) {
const precedingIndex = baseList.findIndex((role) => role.id === precedingRole!.id);
if (precedingIndex === -1) {
throw InputValidationError.fromCode('preceding_role_id', ValidationErrorCodes.PRECEDING_ROLE_NOT_IN_GUILD);
}
insertIndex = precedingIndex + 1;
}
}
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
if (!isOwner) {
const member = await this.guildMemberRepository.getMember(guildId, userId);
if (member) {
myHighestRole = this.getUserHighestRole(member, allRoles);
}
}
const canManageRole = (role: GuildRole): boolean => {
if (isOwner) return true;
if (role.id === everyoneRoleId) return false;
if (!myHighestRole) return false;
return this.isRoleHigherThan(myHighestRole, role);
};
if (!canManageRole(targetRole)) {
throw new MissingPermissionsError();
}
if (insertIndex < originalIndex) {
const rolesToCross = sortedRoles.slice(insertIndex, originalIndex);
for (const role of rolesToCross) {
if (!canManageRole(role)) {
throw new MissingPermissionsError();
}
}
}
if (customOrder) {
baseList.splice(0, baseList.length, ...sortedRoles.filter((role) => role.id !== everyoneRoleId));
} else {
baseList.splice(insertIndex, 0, targetRole);
}
const finalOrder = customOrder
? customOrder.filter((roleId) => roleId !== everyoneRoleId)
: baseList.map((role) => role.id).filter((roleId) => roleId !== everyoneRoleId);
const reorderedRoles = this.reorderRolePositions({
allRoles,
reorderedIds: finalOrder,
guildId,
});
const reorderedRoles = this.reorderRolePositions({allRoles, reorderedIds, guildId});
const updatePromises = reorderedRoles.map((role) => this.guildRoleRepository.upsertRole(role.toRow()));
await Promise.all(updatePromises);
@@ -24,6 +24,7 @@ import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelServi
import {BatchBuilder} from '@fluxer/api/src/database/Cassandra';
import type {GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildDataHelpers} from '@fluxer/api/src/guild/services/data/GuildDataHelpers';
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
@@ -38,6 +39,7 @@ import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddlewa
import {Guild} from '@fluxer/api/src/models/Guild';
import type {User} from '@fluxer/api/src/models/User';
import {getGuildSearchService} from '@fluxer/api/src/SearchFactory';
import type {GuildDiscoveryContext} from '@fluxer/api/src/search/guild/GuildSearchSerializer';
import {
Channels,
ChannelsByGuild,
@@ -103,6 +105,7 @@ export class GuildOperationsService {
private readonly webhookRepository: IWebhookRepository,
private readonly helpers: GuildDataHelpers,
private readonly limitConfigService: LimitConfigService,
private readonly discoveryRepository: IGuildDiscoveryRepository,
private readonly guildManagedTraitService?: GuildManagedTraitService,
) {}
@@ -773,7 +776,17 @@ export class GuildOperationsService {
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
await guildSearchService.updateGuild(updatedGuild).catch((error) => {
let discoveryContext: GuildDiscoveryContext | undefined;
if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) {
const discoveryRow = await this.discoveryRepository.findByGuildId(updatedGuild.id).catch(() => null);
if (discoveryRow) {
discoveryContext = {
description: discoveryRow.description,
categoryId: discoveryRow.category_type,
};
}
}
await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => {
Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
});
}