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

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

View File

@@ -362,6 +362,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
enabled: master.federation.enabled,
}
: undefined,
discovery: {
enabled: master.discovery.enabled,
minMemberCount: master.discovery.min_member_count,
},
dev: {
relaxRegistrationRateLimits: master.dev.relax_registration_rate_limits,
disableRateLimits: master.dev.disable_rate_limits,

View File

@@ -36,6 +36,7 @@ import {SystemDmService} from '@fluxer/api/src/admin/services/SystemDmService';
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AttachmentID, ChannelID, GuildID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {GuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
@@ -206,6 +207,7 @@ export class AdminService {
gatewayService: this.gatewayService,
entityAssetService: this.entityAssetService,
auditService: this.auditService,
discoveryRepository: new GuildDiscoveryRepository(),
});
this.assetPurgeService = new AdminAssetPurgeService({
guildRepository: this.guildRepository,

View File

@@ -41,7 +41,7 @@ function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
guild_id: row.guild_id.toString(),
status: row.status,
description: row.description,
category_id: row.category_id,
category_type: row.category_type,
applied_at: row.applied_at.toISOString(),
reviewed_at: row.reviewed_at?.toISOString() ?? null,
review_reason: row.review_reason ?? null,

View File

@@ -27,6 +27,7 @@ import {AdminGuildUpdateService} from '@fluxer/api/src/admin/services/guild/Admi
import {AdminGuildVanityService} from '@fluxer/api/src/admin/services/guild/AdminGuildVanityService';
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
@@ -60,6 +61,7 @@ interface AdminGuildServiceDeps {
gatewayService: IGatewayService;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
discoveryRepository: IGuildDiscoveryRepository;
}
export class AdminGuildService {
@@ -74,6 +76,7 @@ export class AdminGuildService {
constructor(deps: AdminGuildServiceDeps) {
this.updatePropagator = new AdminGuildUpdatePropagator({
gatewayService: deps.gatewayService,
discoveryRepository: deps.discoveryRepository,
});
this.lookupService = new AdminGuildLookupService({

View File

@@ -19,22 +19,45 @@
import type {GuildID} from '@fluxer/api/src/BrandedTypes';
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildDiscoveryRepository} from '@fluxer/api/src/guild/repositories/GuildDiscoveryRepository';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import type {Guild} from '@fluxer/api/src/models/Guild';
import {getGuildSearchService} from '@fluxer/api/src/SearchFactory';
import type {GuildDiscoveryContext} from '@fluxer/api/src/search/guild/GuildSearchSerializer';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
interface AdminGuildUpdatePropagatorDeps {
gatewayService: IGatewayService;
discoveryRepository: IGuildDiscoveryRepository;
}
export class AdminGuildUpdatePropagator {
constructor(private readonly deps: AdminGuildUpdatePropagatorDeps) {}
async dispatchGuildUpdate(guildId: GuildID, updatedGuild: Guild): Promise<void> {
const {gatewayService} = this.deps;
const {gatewayService, discoveryRepository} = this.deps;
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
let discoveryContext: GuildDiscoveryContext | undefined;
if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) {
const discoveryRow = await discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
discoveryContext = {
description: discoveryRow.description,
categoryId: discoveryRow.category_type,
};
}
}
await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => {
Logger.error({guildId, error}, 'Failed to update guild in search after admin update');
});
}
}
}

View File

@@ -30,7 +30,10 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function createGuildWithApplication(
@@ -45,7 +48,7 @@ async function createGuildWithApplication(
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -23,6 +23,7 @@ import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/reposito
import type {ChannelAuthService} from '@fluxer/api/src/channel/services/channel_data/ChannelAuthService';
import type {ChannelUtilsService} from '@fluxer/api/src/channel/services/channel_data/ChannelUtilsService';
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import {ChannelHelpers} from '@fluxer/api/src/guild/services/channel/ChannelHelpers';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
@@ -404,6 +405,30 @@ export class ChannelOperationsService {
});
await this.channelRepository.channelData.delete(channelId, guildId);
const guildModel = await this.guildRepository.findUnique(guildId);
if (guildModel) {
const guildRow = guildModel.toRow();
const needsUpdate =
guildRow.system_channel_id === channelId ||
guildRow.rules_channel_id === channelId ||
guildRow.afk_channel_id === channelId;
if (needsUpdate) {
const updatedGuild = await this.guildRepository.upsert({
...guildRow,
system_channel_id: guildRow.system_channel_id === channelId ? null : guildRow.system_channel_id,
rules_channel_id: guildRow.rules_channel_id === channelId ? null : guildRow.rules_channel_id,
afk_channel_id: guildRow.afk_channel_id === channelId ? null : guildRow.afk_channel_id,
});
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(updatedGuild),
});
}
}
} else {
await this.userRepository.closeDmForUser(userId, channelId);
await this.channelUtilsService.dispatchDmChannelDelete({channel, userId, requestCache});

View File

@@ -246,7 +246,7 @@ export class MessageSendService {
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
}
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,
@@ -315,7 +315,7 @@ export class MessageSendService {
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,
@@ -785,7 +785,7 @@ export class MessageSendService {
mentionHere: boolean;
}
| undefined;
if (channel && !isForwardMessage && (data.content !== undefined || referencedMessage !== null)) {
if (channel && !isForwardMessage && (data.content !== undefined || data.message_reference != null)) {
const mentionContent = data.content ?? '';
const mentions = this.deps.mentionService.extractMentions({
content: mentionContent,

View File

@@ -248,6 +248,11 @@ export interface APIConfig {
enabled: boolean;
};
discovery: {
enabled: boolean;
minMemberCount: number;
};
dev: {
relaxRegistrationRateLimits: boolean;
disableRateLimits: boolean;

View File

@@ -24,7 +24,7 @@ type Nullish<T> = T | null;
export interface GuildDiscoveryRow {
guild_id: GuildID;
status: string;
category_id: number;
category_type: number;
description: string;
applied_at: Date;
reviewed_at: Nullish<Date>;
@@ -38,7 +38,7 @@ export interface GuildDiscoveryRow {
export const GUILD_DISCOVERY_COLUMNS = [
'guild_id',
'status',
'category_id',
'category_type',
'description',
'applied_at',
'reviewed_at',

View File

@@ -18,6 +18,7 @@
*/
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
@@ -28,7 +29,7 @@ import {Validator} from '@fluxer/api/src/Validator';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {DiscoveryApplicationNotFoundError} from '@fluxer/errors/src/domains/discovery/DiscoveryApplicationNotFoundError';
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
@@ -38,14 +39,21 @@ import {
DiscoveryCategoryListResponse,
DiscoveryGuildListResponse,
DiscoverySearchQuery,
DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
function ensureDiscoveryEnabled(): void {
if (!Config.discovery.enabled) {
throw new DiscoveryDisabledError();
}
}
function mapDiscoveryRowToResponse(row: GuildDiscoveryRow) {
return {
guild_id: row.guild_id.toString(),
status: row.status,
description: row.description,
category_id: row.category_id,
category_type: row.category_type,
applied_at: row.applied_at.toISOString(),
reviewed_at: row.reviewed_at?.toISOString() ?? null,
review_reason: row.review_reason ?? null,
@@ -68,6 +76,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const query = ctx.req.valid('query');
const discoveryService = ctx.get('discoveryService');
@@ -120,6 +129,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -157,6 +167,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -175,7 +186,7 @@ export function GuildDiscoveryController(app: HonoApp) {
guildId,
userId: user.id,
description: data.description,
categoryId: data.category_id,
categoryId: data.category_type,
});
return ctx.json(mapDiscoveryRowToResponse(row));
@@ -199,6 +210,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -239,6 +251,7 @@ export function GuildDiscoveryController(app: HonoApp) {
tags: ['Discovery'],
}),
async (ctx) => {
ensureDiscoveryEnabled();
const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
@@ -266,8 +279,8 @@ export function GuildDiscoveryController(app: HonoApp) {
OpenAPI({
operationId: 'get_discovery_status',
summary: 'Get discovery status',
description: 'Get the current discovery status of a guild. Requires MANAGE_GUILD permission.',
responseSchema: DiscoveryApplicationResponse,
description: 'Get the current discovery status and eligibility of a guild. Requires MANAGE_GUILD permission.',
responseSchema: DiscoveryStatusResponse,
statusCode: 200,
security: ['sessionToken', 'bearerToken', 'botToken'],
tags: ['Discovery'],
@@ -286,12 +299,15 @@ export function GuildDiscoveryController(app: HonoApp) {
throw new MissingPermissionsError();
}
const row = await ctx.get('discoveryService').getStatus(guildId);
if (!row) {
throw new DiscoveryApplicationNotFoundError();
}
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.getStatus(guildId);
const eligibility = await discoveryService.getEligibility(guildId);
return ctx.json(mapDiscoveryRowToResponse(row));
return ctx.json({
application: row ? mapDiscoveryRowToResponse(row) : null,
eligible: Config.discovery.enabled && eligibility.eligible,
min_member_count: eligibility.min_member_count,
});
},
);
}

View File

@@ -21,6 +21,7 @@ import type {GuildID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {GuildDiscoveryByStatusRow, GuildDiscoveryRow} from '@fluxer/api/src/database/types/GuildDiscoveryTypes';
import {GuildDiscovery, GuildDiscoveryByStatus} from '@fluxer/api/src/Tables';
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
const FETCH_DISCOVERY_BY_GUILD_ID = GuildDiscovery.selectCql({
where: GuildDiscovery.where.eq('guild_id'),
@@ -46,9 +47,13 @@ export abstract class IGuildDiscoveryRepository {
export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
async findByGuildId(guildId: GuildID): Promise<GuildDiscoveryRow | null> {
return fetchOne<GuildDiscoveryRow>(FETCH_DISCOVERY_BY_GUILD_ID, {
const row = await fetchOne<GuildDiscoveryRow>(FETCH_DISCOVERY_BY_GUILD_ID, {
guild_id: guildId,
});
if (row) {
row.category_type ??= DiscoveryCategories.GAMING;
}
return row;
}
async listByStatus(status: string, limit: number): Promise<Array<GuildDiscoveryByStatusRow>> {
@@ -64,7 +69,7 @@ export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
GuildDiscovery.insert({
guild_id: row.guild_id,
status: row.status,
category_id: row.category_id,
category_type: row.category_type,
description: row.description,
applied_at: row.applied_at,
reviewed_at: row.reviewed_at,
@@ -116,7 +121,7 @@ export class GuildDiscoveryRepository extends IGuildDiscoveryRepository {
GuildDiscovery.insert({
guild_id: updatedRow.guild_id,
status: updatedRow.status,
category_id: updatedRow.category_id,
category_type: updatedRow.category_type,
description: updatedRow.description,
applied_at: updatedRow.applied_at,
reviewed_at: updatedRow.reviewed_at,

View File

@@ -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,
);

View File

@@ -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),

View File

@@ -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);

View File

@@ -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');
});
}

View File

@@ -22,14 +22,19 @@ import {createGuild, getGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils'
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {
DiscoveryApplicationResponse,
DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function applyForDiscovery(
@@ -37,11 +42,11 @@ async function applyForDiscovery(
token: string,
guildId: string,
description = 'A great community for testing discovery features',
categoryId = DiscoveryCategories.GAMING,
categoryId: DiscoveryCategory = DiscoveryCategories.GAMING,
): Promise<DiscoveryApplicationResponse> {
return createBuilder<DiscoveryApplicationResponse>(harness, token)
.post(`/guilds/${guildId}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();
}
@@ -98,7 +103,7 @@ describe('Discovery Application Lifecycle', () => {
expect(application.guild_id).toBe(guild.id);
expect(application.status).toBe('pending');
expect(application.description).toBe('A great community for testing discovery features');
expect(application.category_id).toBe(DiscoveryCategories.GAMING);
expect(application.category_type).toBe(DiscoveryCategories.GAMING);
expect(application.applied_at).toBeTruthy();
expect(application.reviewed_at).toBeNull();
expect(application.review_reason).toBeNull();
@@ -111,13 +116,16 @@ describe('Discovery Application Lifecycle', () => {
await applyForDiscovery(harness, owner.token, guild.id);
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.guild_id).toBe(guild.id);
expect(status.status).toBe('pending');
expect(status.application).not.toBeNull();
expect(status.application!.guild_id).toBe(guild.id);
expect(status.application!.status).toBe('pending');
expect(status.eligible).toBe(true);
expect(status.min_member_count).toBeGreaterThan(0);
});
test('should edit pending application description', async () => {
@@ -134,7 +142,7 @@ describe('Discovery Application Lifecycle', () => {
.execute();
expect(updated.description).toBe('Updated community description');
expect(updated.category_id).toBe(DiscoveryCategories.GAMING);
expect(updated.category_type).toBe(DiscoveryCategories.GAMING);
});
test('should edit pending application category', async () => {
@@ -146,11 +154,11 @@ describe('Discovery Application Lifecycle', () => {
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.patch(`/guilds/${guild.id}/discovery`)
.body({category_id: DiscoveryCategories.EDUCATION})
.body({category_type: DiscoveryCategories.EDUCATION})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.category_id).toBe(DiscoveryCategories.EDUCATION);
expect(updated.category_type).toBe(DiscoveryCategories.EDUCATION);
});
test('should withdraw pending application', async () => {
@@ -165,10 +173,12 @@ describe('Discovery Application Lifecycle', () => {
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.application).toBeNull();
});
test('should complete full lifecycle: apply → approve → verify feature → withdraw', async () => {
@@ -207,11 +217,11 @@ describe('Discovery Application Lifecycle', () => {
await adminReject(harness, admin.token, guild.id, 'Needs more detail');
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.status).toBe('rejected');
expect(status.application!.status).toBe('rejected');
const reapplication = await applyForDiscovery(
harness,
@@ -266,7 +276,7 @@ describe('Discovery Application Lifecycle', () => {
'Valid description for this category',
categoryId,
);
expect(application.category_id).toBe(categoryId);
expect(application.category_type).toBe(categoryId);
}
});

View File

@@ -24,11 +24,17 @@ import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {
DiscoveryApplicationResponse,
DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
describe('Discovery Application Validation', () => {
@@ -50,7 +56,7 @@ describe('Discovery Application Validation', () => {
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Small but active community', category_id: DiscoveryCategories.GAMING})
.body({description: 'Small but active community', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -64,7 +70,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'No members yet', category_id: DiscoveryCategories.GAMING})
.body({description: 'No members yet', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS)
.execute();
});
@@ -78,7 +84,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: 99})
.body({description: 'Valid description here', category_type: 99})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -90,7 +96,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: -1})
.body({description: 'Valid description here', category_type: -1})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -102,13 +108,13 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Valid description here', category_id: DiscoveryCategories.GAMING})
.body({description: 'Valid description here', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/discovery`)
.body({category_id: 99})
.body({category_type: 99})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -122,7 +128,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Too short', category_id: DiscoveryCategories.GAMING})
.body({description: 'Too short', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -134,7 +140,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'A'.repeat(301), category_id: DiscoveryCategories.GAMING})
.body({description: 'A'.repeat(301), category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
@@ -146,12 +152,12 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({category_id: DiscoveryCategories.GAMING})
.body({category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject missing category_id', async () => {
test('should reject missing category_type', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'No Cat Guild');
await setGuildMemberCount(harness, guild.id, 1);
@@ -172,13 +178,13 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'First application attempt', category_id: DiscoveryCategories.GAMING})
.body({description: 'First application attempt', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Second application attempt', category_id: DiscoveryCategories.GAMING})
.body({description: 'Second application attempt', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
.execute();
});
@@ -192,7 +198,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Application to be approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Application to be approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -204,7 +210,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Trying to reapply while approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Trying to reapply while approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
.execute();
});
@@ -218,7 +224,7 @@ describe('Discovery Application Validation', () => {
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Should not be allowed', category_id: DiscoveryCategories.GAMING})
.body({description: 'Should not be allowed', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
@@ -230,7 +236,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
.body({description: 'Owner applied for discovery', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -248,7 +254,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
.body({description: 'Owner applied for discovery', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -273,7 +279,7 @@ describe('Discovery Application Validation', () => {
test('should require login to apply', async () => {
await createBuilderWithoutAuth(harness)
.post(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
.body({description: 'No auth attempt', category_id: DiscoveryCategories.GAMING})
.body({description: 'No auth attempt', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
@@ -302,14 +308,17 @@ describe('Discovery Application Validation', () => {
});
describe('non-existent application', () => {
test('should return error when getting status with no application', async () => {
test('should return null application when none exists', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'No App Guild');
await createBuilder(harness, owner.token)
const status = await createBuilder<DiscoveryStatusResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/discovery`)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.application).toBeNull();
expect(status.min_member_count).toBeGreaterThan(0);
});
test('should return error when editing non-existent application', async () => {
@@ -344,7 +353,7 @@ describe('Discovery Application Validation', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'To be rejected for edit test', category_id: DiscoveryCategories.GAMING})
.body({description: 'To be rejected for edit test', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -32,7 +32,10 @@ import type {
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
await createBuilder(harness, '')
.post(`/test/guilds/${guildId}/member-count`)
.body({member_count: memberCount})
.execute();
}
async function applyAndApprove(
@@ -45,7 +48,7 @@ async function applyAndApprove(
): Promise<void> {
await createBuilder<DiscoveryApplicationResponse>(harness, ownerToken)
.post(`/guilds/${guildId}/discovery`)
.body({description, category_id: categoryId})
.body({description, category_type: categoryId})
.expect(HTTP_STATUS.OK)
.execute();
@@ -102,10 +105,7 @@ describe('Discovery Search and Join', () => {
});
test('should require login to list categories', async () => {
await createBuilderWithoutAuth(harness)
.get('/discovery/categories')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilderWithoutAuth(harness).get('/discovery/categories').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});
@@ -150,7 +150,7 @@ describe('Discovery Search and Join', () => {
expect(found).toBeDefined();
expect(found!.name).toBe('Searchable Guild');
expect(found!.description).toBe('A searchable community for all');
expect(found!.category_id).toBe(DiscoveryCategories.GAMING);
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
});
test('should not return pending guilds in search results', async () => {
@@ -160,7 +160,7 @@ describe('Discovery Search and Join', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Pending application guild', category_id: DiscoveryCategories.GAMING})
.body({description: 'Pending application guild', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();
@@ -181,12 +181,26 @@ describe('Discovery Search and Join', () => {
const owner1 = await createTestAccount(harness);
const gamingGuild = await createGuild(harness, owner1.token, 'Gaming Community');
await setGuildMemberCount(harness, gamingGuild.id, 10);
await applyAndApprove(harness, owner1.token, admin.token, gamingGuild.id, 'All about gaming', DiscoveryCategories.GAMING);
await applyAndApprove(
harness,
owner1.token,
admin.token,
gamingGuild.id,
'All about gaming',
DiscoveryCategories.GAMING,
);
const owner2 = await createTestAccount(harness);
const musicGuild = await createGuild(harness, owner2.token, 'Music Community');
await setGuildMemberCount(harness, musicGuild.id, 10);
await applyAndApprove(harness, owner2.token, admin.token, musicGuild.id, 'All about music', DiscoveryCategories.MUSIC);
await applyAndApprove(
harness,
owner2.token,
admin.token,
musicGuild.id,
'All about music',
DiscoveryCategories.MUSIC,
);
const searcher = await createTestAccount(harness);
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
@@ -195,7 +209,7 @@ describe('Discovery Search and Join', () => {
.execute();
for (const guild of results.guilds) {
expect(guild.category_id).toBe(DiscoveryCategories.GAMING);
expect(guild.category_type).toBe(DiscoveryCategories.GAMING);
}
});
@@ -207,7 +221,14 @@ describe('Discovery Search and Join', () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, `Limit Test Guild ${i}`);
await setGuildMemberCount(harness, guild.id, 10);
await applyAndApprove(harness, owner.token, admin.token, guild.id, `Community number ${i} for testing`, DiscoveryCategories.GAMING);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
`Community number ${i} for testing`,
DiscoveryCategories.GAMING,
);
}
const searcher = await createTestAccount(harness);
@@ -220,10 +241,7 @@ describe('Discovery Search and Join', () => {
});
test('should require login to search', async () => {
await createBuilderWithoutAuth(harness)
.get('/discovery/guilds')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});
@@ -235,7 +253,14 @@ describe('Discovery Search and Join', () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
await applyAndApprove(harness, owner.token, admin.token, guild.id, 'Join this community', DiscoveryCategories.GAMING);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
'Join this community',
DiscoveryCategories.GAMING,
);
const joiner = await createTestAccount(harness);
await createBuilder(harness, joiner.token)
@@ -266,7 +291,7 @@ describe('Discovery Search and Join', () => {
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Pending but not yet approved', category_id: DiscoveryCategories.GAMING})
.body({description: 'Pending but not yet approved', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.OK)
.execute();

View File

@@ -0,0 +1,396 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
addMemberRole,
createChannelInvite,
createGuild,
createRole,
getChannel,
getRoles,
updateRolePositions,
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Guild Role Reorder', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('Owner Reordering', () => {
test('should allow owner to reorder any roles', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleC.id, position: 3},
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === roleA.id)!;
const updatedB = roles.find((r) => r.id === roleB.id)!;
const updatedC = roles.find((r) => r.id === roleC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedB.position);
expect(updatedB.position).toBeGreaterThan(updatedA.position);
});
test('should allow owner to reverse all role positions', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 2},
{id: roleB.id, position: 1},
]);
const rolesBefore = await getRoles(harness, owner.token, guild.id);
const beforeA = rolesBefore.find((r) => r.id === roleA.id)!;
const beforeB = rolesBefore.find((r) => r.id === roleB.id)!;
expect(beforeA.position).toBeGreaterThan(beforeB.position);
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const rolesAfter = await getRoles(harness, owner.token, guild.id);
const afterA = rolesAfter.find((r) => r.id === roleA.id)!;
const afterB = rolesAfter.find((r) => r.id === roleB.id)!;
expect(afterB.position).toBeGreaterThan(afterA.position);
});
});
describe('Non-Owner with MANAGE_ROLES', () => {
test('should allow member with MANAGE_ROLES to reorder roles below their highest role', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const lowRoleA = await createRole(harness, owner.token, guild.id, {name: 'Low A'});
const lowRoleB = await createRole(harness, owner.token, guild.id, {name: 'Low B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: managerRole.id, position: 4},
{id: lowRoleA.id, position: 3},
{id: lowRoleB.id, position: 2},
]);
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, manager.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await updateRolePositions(harness, manager.token, guild.id, [
{id: lowRoleB.id, position: 3},
{id: lowRoleA.id, position: 2},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === lowRoleA.id)!;
const updatedB = roles.find((r) => r.id === lowRoleB.id)!;
expect(updatedB.position).toBeGreaterThan(updatedA.position);
});
test('should reject reordering a role above the user highest role', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
await updateRolePositions(harness, owner.token, guild.id, [
{id: highRole.id, position: 4},
{id: managerRole.id, position: 3},
]);
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, manager.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await createBuilder(harness, manager.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: highRole.id, position: 1}])
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should reject reordering that indirectly shifts an unmanageable role', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const lowRole = await createRole(harness, owner.token, guild.id, {name: 'Low Role'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: highRole.id, position: 4},
{id: managerRole.id, position: 3},
{id: lowRole.id, position: 2},
]);
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, manager.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await createBuilder(harness, manager.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: lowRole.id, position: 5}])
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should allow reordering multiple roles below highest when unmanageable roles stay in place', async () => {
const owner = await createTestAccount(harness);
const manager = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const highRole = await createRole(harness, owner.token, guild.id, {name: 'High Role'});
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const lowA = await createRole(harness, owner.token, guild.id, {name: 'Low A'});
const lowB = await createRole(harness, owner.token, guild.id, {name: 'Low B'});
const lowC = await createRole(harness, owner.token, guild.id, {name: 'Low C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: highRole.id, position: 6},
{id: managerRole.id, position: 5},
{id: lowA.id, position: 4},
{id: lowB.id, position: 3},
{id: lowC.id, position: 2},
]);
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, manager.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await updateRolePositions(harness, manager.token, guild.id, [
{id: lowC.id, position: 4},
{id: lowA.id, position: 3},
{id: lowB.id, position: 2},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === lowA.id)!;
const updatedB = roles.find((r) => r.id === lowB.id)!;
const updatedC = roles.find((r) => r.id === lowC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedA.position);
expect(updatedA.position).toBeGreaterThan(updatedB.position);
});
});
describe('Permission Requirements', () => {
test('should require MANAGE_ROLES permission to reorder roles', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
await createBuilder(harness, member.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: role.id, position: 5}])
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should reject reorder from a non-guild member', async () => {
const owner = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
await createBuilder(harness, outsider.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: role.id, position: 5}])
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});
describe('Validation', () => {
test('should reject reordering @everyone role', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: guild.id, position: 5}])
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject reorder with invalid role ID', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles`)
.body([{id: '999999999999999999', position: 1}])
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should accept reorder with no position changes (no-op)', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 2},
{id: roleB.id, position: 1},
]);
const rolesBefore = await getRoles(harness, owner.token, guild.id);
const beforeA = rolesBefore.find((r) => r.id === roleA.id)!;
const beforeB = rolesBefore.find((r) => r.id === roleB.id)!;
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 2},
{id: roleB.id, position: 1},
]);
const rolesAfter = await getRoles(harness, owner.token, guild.id);
const afterA = rolesAfter.find((r) => r.id === roleA.id)!;
const afterB = rolesAfter.find((r) => r.id === roleB.id)!;
expect(afterA.position).toBe(beforeA.position);
expect(afterB.position).toBe(beforeB.position);
});
});
describe('Position Assignment', () => {
test('should keep @everyone at position 0 after reorder', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleB.id, position: 2},
{id: roleA.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const everyone = roles.find((r) => r.id === guild.id)!;
expect(everyone.position).toBe(0);
});
test('should assign positions to all roles after reorder', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 3},
{id: roleB.id, position: 2},
{id: roleC.id, position: 1},
]);
const roles = await getRoles(harness, owner.token, guild.id);
const nonEveryoneRoles = roles.filter((r) => r.id !== guild.id);
for (const role of nonEveryoneRoles) {
expect(role.position).toBeGreaterThan(0);
}
const positions = nonEveryoneRoles.map((r) => r.position);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(nonEveryoneRoles.length);
});
test('should correctly order roles with a partial position update', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const roleA = await createRole(harness, owner.token, guild.id, {name: 'Role A'});
const roleB = await createRole(harness, owner.token, guild.id, {name: 'Role B'});
const roleC = await createRole(harness, owner.token, guild.id, {name: 'Role C'});
await updateRolePositions(harness, owner.token, guild.id, [
{id: roleA.id, position: 3},
{id: roleB.id, position: 2},
{id: roleC.id, position: 1},
]);
await updateRolePositions(harness, owner.token, guild.id, [{id: roleC.id, position: 4}]);
const roles = await getRoles(harness, owner.token, guild.id);
const updatedA = roles.find((r) => r.id === roleA.id)!;
const updatedB = roles.find((r) => r.id === roleB.id)!;
const updatedC = roles.find((r) => r.id === roleC.id)!;
expect(updatedC.position).toBeGreaterThan(updatedA.position);
expect(updatedA.position).toBeGreaterThan(updatedB.position);
});
});
});

View File

@@ -1236,7 +1236,8 @@ export class RpcService {
});
}
const repairedBannerGuild = await this.repairGuildBannerHeight(guildResult);
const repairedChannelRefsGuild = await this.repairDanglingChannelReferences({guild: guildResult, channels});
const repairedBannerGuild = await this.repairGuildBannerHeight(repairedChannelRefsGuild);
const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild);
const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild);
const updatedGuild = await this.updateGuildMemberCount(repairedEmbedSplashGuild, members.length);
@@ -1449,6 +1450,36 @@ export class RpcService {
return hash.startsWith('a_') ? hash.slice(2) : hash;
}
private async repairDanglingChannelReferences(params: {guild: Guild; channels: Array<Channel>}): Promise<Guild> {
const {guild, channels} = params;
const channelIds = new Set(channels.map((channel) => channel.id));
const danglingSystemChannel = guild.systemChannelId != null && !channelIds.has(guild.systemChannelId);
const danglingRulesChannel = guild.rulesChannelId != null && !channelIds.has(guild.rulesChannelId);
const danglingAfkChannel = guild.afkChannelId != null && !channelIds.has(guild.afkChannelId);
if (!danglingSystemChannel && !danglingRulesChannel && !danglingAfkChannel) {
return guild;
}
Logger.info(
{
guildId: guild.id.toString(),
danglingSystemChannel,
danglingRulesChannel,
danglingAfkChannel,
},
'Repairing dangling guild channel references',
);
return this.guildRepository.upsert({
...guild.toRow(),
system_channel_id: danglingSystemChannel ? null : guild.systemChannelId,
rules_channel_id: danglingRulesChannel ? null : guild.rulesChannelId,
afk_channel_id: danglingAfkChannel ? null : guild.afkChannelId,
});
}
private async repairGuildBannerHeight(guild: Guild): Promise<Guild> {
if (!guild.bannerHash || (guild.bannerHeight != null && guild.bannerWidth != null)) {
return guild;

View File

@@ -178,7 +178,7 @@ export class NoopGatewayService extends IGatewayService {
}
async getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise<bigint> {
const {guildId, userId} = params;
const {guildId, userId, channelId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
@@ -195,8 +195,20 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
return this.calculatePermissions(Array.from(member.roleIds), roles, userId === guild.ownerId, guildId);
if (!channelId) {
return guildPermissions;
}
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channel = await channelRepo.findUnique(channelId);
if (!channel) {
return guildPermissions;
}
return this.applyChannelOverwrites(guildPermissions, member.roleIds, channel, userId, guildId);
}
async getUserPermissionsBatch(_params: {
@@ -207,16 +219,12 @@ export class NoopGatewayService extends IGatewayService {
return new Map();
}
async canManageRoles(_params: {
async canManageRoles(params: {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}): Promise<boolean> {
return false;
}
async canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const {guildId, userId, roleId} = params;
const guild = await guildRepository.findUnique(guildId);
@@ -234,16 +242,10 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
return false;
}
const targetRole = roles.find((r) => r.id === roleId);
@@ -251,24 +253,70 @@ export class NoopGatewayService extends IGatewayService {
return false;
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
return userMaxPosition > targetRole.position;
}
async canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const {guildId, userId, roleId} = params;
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return false;
}
const roles = await roleRepository.listRoles(guildId);
const targetRole = roles.find((r) => r.id === roleId);
if (!targetRole) {
return false;
}
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
if (userMaxPosition > targetRole.position) {
return true;
}
if (userMaxPosition === targetRole.position) {
const highestRole = this.getHighestRole(member.roleIds, roles);
if (highestRole) {
return String(highestRole.id) < String(targetRole.id);
}
}
const targetPosition = (targetRole as {position?: number}).position ?? 0;
return userHighestPosition > targetPosition;
return false;
}
private getHighestRole(
memberRoleIds: Set<RoleID>,
allRoles: Array<{id: RoleID; position: number}>,
): {id: RoleID; position: number} | null {
let highest: {id: RoleID; position: number} | null = null;
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (!role) continue;
if (!highest) {
highest = role;
} else if (role.position > highest.position) {
highest = role;
} else if (role.position === highest.position && String(role.id) < String(highest.id)) {
highest = role;
}
}
return highest;
}
async getAssignableRoles(_params: {guildId: GuildID; userId: UserID}): Promise<Array<RoleID>> {
return [];
}
async getUserMaxRolePosition(_params: {guildId: GuildID; userId: UserID}): Promise<number> {
return 0;
async getUserMaxRolePosition(params: {guildId: GuildID; userId: UserID}): Promise<number> {
const {guildId, userId} = params;
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return 0;
}
const roles = await roleRepository.listRoles(guildId);
return this.getMaxRolePosition(member.roleIds, roles);
}
async checkTargetMember(params: {guildId: GuildID; userId: UserID; targetUserId: UserID}): Promise<boolean> {
@@ -283,6 +331,10 @@ export class NoopGatewayService extends IGatewayService {
return true;
}
if (guild.ownerId === targetUserId) {
return false;
}
const member = await guildMemberRepository.getMember(guildId, userId);
const targetMember = await guildMemberRepository.getMember(guildId, targetUserId);
if (!member || !targetMember) {
@@ -290,35 +342,9 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
let targetHighestPosition = -1;
for (const roleId of targetMember.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
targetHighestPosition = Math.max(targetHighestPosition, (role as {position: number}).position);
}
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
}
}
return userHighestPosition > targetHighestPosition;
const userMaxPosition = this.getMaxRolePosition(member.roleIds, roles);
const targetMaxPosition = this.getMaxRolePosition(targetMember.roleIds, roles);
return userMaxPosition > targetMaxPosition;
}
async getViewableChannels(params: {guildId: GuildID; userId: UserID}): Promise<Array<ChannelID>> {
@@ -339,37 +365,21 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild?.ownerId,
guildId,
);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
if ((guildPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return channels.map((ch) => ch.id);
}
const everyoneRoleId = guildIdToRoleId(guildId);
const viewable: Array<ChannelID> = [];
for (const channel of channels) {
let channelPermissions = userPermissions;
if (channel.permissionOverwrites) {
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
channelPermissions = (channelPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
channelPermissions = (channelPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
channelPermissions = (channelPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
}
const channelPermissions = this.applyChannelOverwrites(
guildPermissions,
member.roleIds,
channel,
userId,
guildId,
);
if ((channelPermissions & Permissions.VIEW_CHANNEL) !== 0n) {
viewable.push(channel.id);
}
@@ -492,53 +502,32 @@ export class NoopGatewayService extends IGatewayService {
}
const roles = await roleRepository.listRoles(guildId);
const guildPermissions = this.calculateGuildPermissions(member.roleIds, roles, guildId);
let userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((guildPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
let userPermissions = guildPermissions;
if (channelId) {
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channel = await channelRepo.findUnique(channelId);
if (channel?.permissionOverwrites) {
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
userPermissions = (userPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
userPermissions = (userPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
userPermissions = (userPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
if (channel) {
userPermissions = this.applyChannelOverwrites(guildPermissions, member.roleIds, channel, userId, guildId);
}
}
return (userPermissions & permission) === permission;
}
private calculatePermissions(
memberRoleIds: Array<RoleID>,
private calculateGuildPermissions(
memberRoleIds: Set<RoleID>,
allRoles: Array<{id: RoleID; permissions: bigint}>,
isOwner: boolean,
guildId: GuildID,
): bigint {
if (isOwner) {
return ALL_PERMISSIONS;
}
let permissions = 0n;
const everyoneRoleId = guildIdToRoleId(guildId);
@@ -550,11 +539,10 @@ export class NoopGatewayService extends IGatewayService {
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (role) {
const rolePermissions = role.permissions;
permissions |= rolePermissions;
permissions |= role.permissions;
if ((rolePermissions & Permissions.ADMINISTRATOR) !== 0n) {
return Permissions.ADMINISTRATOR;
if ((permissions & Permissions.ADMINISTRATOR) !== 0n) {
return ALL_PERMISSIONS;
}
}
}
@@ -562,6 +550,59 @@ export class NoopGatewayService extends IGatewayService {
return permissions;
}
private applyChannelOverwrites(
basePermissions: bigint,
memberRoleIds: Set<RoleID>,
channel: {permissionOverwrites?: Map<RoleID | UserID, {allow: bigint; deny: bigint}>},
userId: UserID,
guildId: GuildID,
): bigint {
if ((basePermissions & Permissions.ADMINISTRATOR) !== 0n) {
return ALL_PERMISSIONS;
}
if (!channel.permissionOverwrites) {
return basePermissions;
}
let permissions = basePermissions;
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
permissions = (permissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
let roleAllow = 0n;
let roleDeny = 0n;
for (const roleId of memberRoleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
roleAllow |= overwrite.allow;
roleDeny |= overwrite.deny;
}
}
permissions = (permissions & ~roleDeny) | roleAllow;
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
permissions = (permissions & ~userOverwrite.deny) | userOverwrite.allow;
}
return permissions;
}
private getMaxRolePosition(memberRoleIds: Set<RoleID>, allRoles: Array<{id: RoleID; position: number}>): number {
let maxPosition = -1;
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (role) {
maxPosition = Math.max(maxPosition, role.position);
}
}
return maxPosition;
}
async getVanityUrlChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}

View File

@@ -21,11 +21,7 @@ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {
deletePushSubscription,
listPushSubscriptions,
subscribePush,
} from '@fluxer/api/src/user/tests/UserTestUtils';
import {deletePushSubscription, listPushSubscriptions, subscribePush} from '@fluxer/api/src/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('Push Subscription Lifecycle', () => {
@@ -180,10 +176,7 @@ describe('Push Subscription Lifecycle', () => {
});
test('list subscriptions requires authentication', async () => {
await createBuilder(harness, '')
.get('/users/@me/push/subscriptions')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilder(harness, '').get('/users/@me/push/subscriptions').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
test('delete subscription requires authentication', async () => {

View File

@@ -378,9 +378,7 @@ export async function listPushSubscriptions(
harness: ApiTestHarness,
token: string,
): Promise<PushSubscriptionsListResponse> {
return createBuilder<PushSubscriptionsListResponse>(harness, token)
.get('/users/@me/push/subscriptions')
.execute();
return createBuilder<PushSubscriptionsListResponse>(harness, token).get('/users/@me/push/subscriptions').execute();
}
export async function deletePushSubscription(
@@ -388,7 +386,5 @@ export async function deletePushSubscription(
token: string,
subscriptionId: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/users/@me/push/subscriptions/${subscriptionId}`)
.execute();
await createBuilder<void>(harness, token).delete(`/users/@me/push/subscriptions/${subscriptionId}`).execute();
}

View File

@@ -75,8 +75,8 @@ describe('GitHub Issue Transformer', () => {
expect(result?.url).toBe('https://github.com/org/repo/issues/99');
expect(result?.color).toBe(0xeb4841);
expect(result?.description).toContain('When I try to do X');
expect(result?.author?.name).toBe('reporter');
expect(result?.author?.url).toBe('https://github.com/reporter');
expect(result?.author?.name).toBe('testuser');
expect(result?.author?.url).toBe('https://github.com/testuser');
});
it('transforms a closed issue', async () => {
@@ -93,6 +93,7 @@ describe('GitHub Issue Transformer', () => {
expect(result?.title).toContain('Issue closed');
expect(result?.color).toBe(0x000000);
expect(result?.description).toBeUndefined();
expect(result?.author?.name).toBe('testuser');
});
it('transforms a reopened issue', async () => {
@@ -108,6 +109,7 @@ describe('GitHub Issue Transformer', () => {
expect(result).not.toBeNull();
expect(result?.title).toContain('Issue reopened');
expect(result?.color).toBe(0xfcbd1f);
expect(result?.author?.name).toBe('testuser');
});
it('returns null for unsupported action types', async () => {

View File

@@ -26,9 +26,9 @@ export async function transformIssue(body: GitHubWebhook): Promise<RichEmbedRequ
return null;
}
const authorIconUrl = body.issue.user.avatar_url;
const authorName = body.issue.user.login;
const authorUrl = body.issue.user.html_url;
const authorIconUrl = body.sender.avatar_url;
const authorName = body.sender.login;
const authorUrl = body.sender.html_url;
const repoName = body.repository.full_name;
const issueNumber = body.issue.number;
const issueTitle = body.issue.title;

View File

@@ -67,7 +67,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
await guildSearchService.updateGuild(guild, {
description: discoveryRow.description,
categoryId: discoveryRow.category_id,
categoryId: discoveryRow.category_type,
onlineCount: onlineCounts.get(guildId) ?? 0,
});