mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(discovery): more work on discovery plus a few fixes
This commit is contained in:
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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user