feat(discovery): more work on discovery plus a few fixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -248,6 +248,11 @@ export interface APIConfig {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
discovery: {
|
||||
enabled: boolean;
|
||||
minMemberCount: number;
|
||||
};
|
||||
|
||||
dev: {
|
||||
relaxRegistrationRateLimits: boolean;
|
||||
disableRateLimits: boolean;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
396
packages/api/src/guild/tests/GuildRoleReorder.test.tsx
Normal file
396
packages/api/src/guild/tests/GuildRoleReorder.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user