feat(discovery): more work on discovery plus a few fixes
This commit is contained in:
@@ -190,7 +190,7 @@ export const DiscoveryPage: FC<DiscoveryPageProps> = ({
|
||||
{app.guild_id}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{getCategoryLabel(app.category_id)}</TableCell>
|
||||
<TableCell>{getCategoryLabel(app.category_type)}</TableCell>
|
||||
<TableCell>
|
||||
<span class="block max-w-xs truncate" title={app.description}>
|
||||
{app.description}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,11 @@
|
||||
"$ref": "#/$defs/proxy",
|
||||
"default": {}
|
||||
},
|
||||
"discovery": {
|
||||
"description": "Guild discovery listing configuration.",
|
||||
"$ref": "#/$defs/discovery",
|
||||
"default": {}
|
||||
},
|
||||
"attachment_decay_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to automatically delete old attachments.",
|
||||
@@ -570,6 +575,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"description": "Guild discovery listing configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether guild discovery is enabled on this instance.",
|
||||
"default": true
|
||||
},
|
||||
"min_member_count": {
|
||||
"type": "number",
|
||||
"description": "Minimum number of members a guild needs before it can apply for discovery listing.",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"type": "object",
|
||||
"description": "Configuration for domains and ports used to construct public URLs.",
|
||||
|
||||
19
packages/config/src/schema/defs/discovery.json
Normal file
19
packages/config/src/schema/defs/discovery.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"description": "Guild discovery listing configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether guild discovery is enabled on this instance.",
|
||||
"default": true
|
||||
},
|
||||
"min_member_count": {
|
||||
"type": "number",
|
||||
"description": "Minimum number of members a guild needs before it can apply for discovery listing.",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,11 @@
|
||||
"$ref": "#/$defs/proxy",
|
||||
"default": {}
|
||||
},
|
||||
"discovery": {
|
||||
"description": "Guild discovery listing configuration.",
|
||||
"$ref": "#/$defs/discovery",
|
||||
"default": {}
|
||||
},
|
||||
"attachment_decay_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to automatically delete old attachments.",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const APIErrorCodes = {
|
||||
DISCOVERY_APPLICATION_ALREADY_REVIEWED: 'DISCOVERY_APPLICATION_ALREADY_REVIEWED',
|
||||
DISCOVERY_APPLICATION_NOT_FOUND: 'DISCOVERY_APPLICATION_NOT_FOUND',
|
||||
DISCOVERY_DESCRIPTION_REQUIRED: 'DISCOVERY_DESCRIPTION_REQUIRED',
|
||||
DISCOVERY_DISABLED: 'DISCOVERY_DISABLED',
|
||||
DISCOVERY_INSUFFICIENT_MEMBERS: 'DISCOVERY_INSUFFICIENT_MEMBERS',
|
||||
DISCOVERY_INVALID_CATEGORY: 'DISCOVERY_INVALID_CATEGORY',
|
||||
DISCOVERY_NOT_DISCOVERABLE: 'DISCOVERY_NOT_DISCOVERABLE',
|
||||
|
||||
@@ -86,6 +86,7 @@ export const APIErrorCodesDescriptions: Record<keyof typeof APIErrorCodes, strin
|
||||
DISCOVERY_APPLICATION_ALREADY_REVIEWED: 'This discovery application has already been reviewed',
|
||||
DISCOVERY_APPLICATION_NOT_FOUND: 'Discovery application not found',
|
||||
DISCOVERY_DESCRIPTION_REQUIRED: 'A description is required for discovery',
|
||||
DISCOVERY_DISABLED: 'Discovery is not available on this instance',
|
||||
DISCOVERY_INSUFFICIENT_MEMBERS: 'Community does not meet the minimum member count for discovery',
|
||||
DISCOVERY_INVALID_CATEGORY: 'Invalid discovery category',
|
||||
DISCOVERY_NOT_DISCOVERABLE: 'This community is not listed in discovery',
|
||||
|
||||
@@ -54,7 +54,5 @@ export const DiscoveryApplicationStatus = {
|
||||
|
||||
export type DiscoveryApplicationStatusValue = ValueOf<typeof DiscoveryApplicationStatus>;
|
||||
|
||||
export const DISCOVERY_MIN_MEMBER_COUNT = 50;
|
||||
export const DISCOVERY_MIN_MEMBER_COUNT_DEV = 1;
|
||||
export const DISCOVERY_DESCRIPTION_MIN_LENGTH = 10;
|
||||
export const DISCOVERY_DESCRIPTION_MAX_LENGTH = 300;
|
||||
|
||||
@@ -17,24 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
import {Locales} from '@fluxer/constants/src/Locales';
|
||||
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
|
||||
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
|
||||
|
||||
interface MadeInSwedenBadgeProps {
|
||||
ctx: MarketingContext;
|
||||
}
|
||||
|
||||
export function MadeInSwedenBadge(props: MadeInSwedenBadgeProps): JSX.Element {
|
||||
const {ctx} = props;
|
||||
|
||||
return (
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
|
||||
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-4 w-4 rounded-sm" />
|
||||
<span>{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}</span>
|
||||
</span>
|
||||
);
|
||||
export class DiscoveryDisabledError extends BadRequestError {
|
||||
constructor() {
|
||||
super({
|
||||
code: APIErrorCodes.DISCOVERY_DISABLED,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export const ErrorCodeToI18nKey = {
|
||||
[APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED]: 'discovery.application_already_reviewed',
|
||||
[APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND]: 'discovery.application_not_found',
|
||||
[APIErrorCodes.DISCOVERY_DESCRIPTION_REQUIRED]: 'discovery.description_required',
|
||||
[APIErrorCodes.DISCOVERY_DISABLED]: 'discovery.disabled',
|
||||
[APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS]: 'discovery.insufficient_members',
|
||||
[APIErrorCodes.DISCOVERY_INVALID_CATEGORY]: 'discovery.invalid_category',
|
||||
[APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE]: 'discovery.not_discoverable',
|
||||
|
||||
@@ -238,6 +238,7 @@ export type ErrorI18nKey =
|
||||
| 'discovery.application_already_reviewed'
|
||||
| 'discovery.application_not_found'
|
||||
| 'discovery.description_required'
|
||||
| 'discovery.disabled'
|
||||
| 'discovery.insufficient_members'
|
||||
| 'discovery.invalid_category'
|
||||
| 'discovery.not_discoverable'
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: هذا المجتمع تقدّم بالفعل بطل
|
||||
discovery.application_already_reviewed: تمت مراجعة طلب الاستكشاف هذا بالفعل.
|
||||
discovery.application_not_found: طلب الاستكشاف غير موجود.
|
||||
discovery.description_required: الوصف مطلوب لإدراجك في الاستكشاف.
|
||||
discovery.disabled: الاكتشاف غير متاح على هذا المثيل.
|
||||
discovery.insufficient_members: هذا المجتمع ما يحقق الحد الأدنى من الأعضاء للظهور في الاستكشاف.
|
||||
discovery.invalid_category: فئة الاستكشاف غير صالحة.
|
||||
discovery.not_discoverable: هذا المجتمع غير مدرج في الاستكشاف.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Тази общност вече е кандидат
|
||||
discovery.application_already_reviewed: Тази заявка за открития вече е разгледана.
|
||||
discovery.application_not_found: Заявката за открития не беше намерена.
|
||||
discovery.description_required: Описание е задължително за списъка с открития.
|
||||
discovery.disabled: Откритието не е налично на този екземпляр.
|
||||
discovery.insufficient_members: Тази общност не отговаря на минималния брой членове за открития.
|
||||
discovery.invalid_category: Невалидна категория за открития.
|
||||
discovery.not_discoverable: Тази общност не е включена в откритията.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Tato komunita o zápis do discovery už požádala.
|
||||
discovery.application_already_reviewed: Tato žádost o zápis do discovery už byla přezkoumána.
|
||||
discovery.application_not_found: Žádost o zápis do discovery nebyla nalezena.
|
||||
discovery.description_required: Pro zápis do discovery je potřeba popis.
|
||||
discovery.disabled: Objevování není na této instanci k dispozici.
|
||||
discovery.insufficient_members: Tato komunita nesplňuje minimální počet členů pro zápis do discovery.
|
||||
discovery.invalid_category: Neplatná kategorie discovery.
|
||||
discovery.not_discoverable: Tato komunita není zapsaná v discovery.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Dette fællesskab har allerede ansøgt om opdagelse.
|
||||
discovery.application_already_reviewed: Denne opdagelsesansøgning er allerede blevet behandlet.
|
||||
discovery.application_not_found: Opdagelsesansøgningen blev ikke fundet.
|
||||
discovery.description_required: En beskrivelse er påkrævet for at blive vist i opdagelse.
|
||||
discovery.disabled: Opdagelse er ikke tilgængelig på denne forekomst.
|
||||
discovery.insufficient_members: Dette fællesskab opfylder ikke minimumsantallet af medlemmer for opdagelse.
|
||||
discovery.invalid_category: Ugyldig opdagelseskategori.
|
||||
discovery.not_discoverable: Dette fællesskab er ikke vist i opdagelse.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Diese Community hat sich bereits für die Entdeckung
|
||||
discovery.application_already_reviewed: Dieser Entdeckungsantrag wurde bereits geprüft.
|
||||
discovery.application_not_found: Entdeckungsantrag wurde nicht gefunden.
|
||||
discovery.description_required: Eine Beschreibung ist für den Entdeckungseintrag erforderlich.
|
||||
discovery.disabled: Entdeckung ist auf dieser Instanz nicht verfügbar.
|
||||
discovery.insufficient_members: Diese Community erfüllt nicht die Mindestanzahl an Mitgliedern für die Entdeckung.
|
||||
discovery.invalid_category: Ungültige Entdeckungskategorie.
|
||||
discovery.not_discoverable: Diese Community ist nicht in der Entdeckung gelistet.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Αυτή η κοινότητα έχει ήδη υπο
|
||||
discovery.application_already_reviewed: Αυτή η αίτηση ανακάλυψης έχει ήδη εξεταστεί.
|
||||
discovery.application_not_found: Η αίτηση ανακάλυψης δεν βρέθηκε.
|
||||
discovery.description_required: Απαιτείται περιγραφή για την καταχώρηση στην ανακάλυψη.
|
||||
discovery.disabled: Η ανακάλυψη δεν είναι διαθέσιμη σε αυτήν την παρουσία.
|
||||
discovery.insufficient_members: Αυτή η κοινότητα δεν πληροί τον ελάχιστο αριθμό μελών για ανακάλυψη.
|
||||
discovery.invalid_category: Μη έγκυρη κατηγορία ανακάλυψης.
|
||||
discovery.not_discoverable: Αυτή η κοινότητα δεν είναι καταχωρημένη στην ανακάλυψη.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: This community has already applied for discovery.
|
||||
discovery.application_already_reviewed: This discovery application has already been reviewed.
|
||||
discovery.application_not_found: Discovery application wasn't found.
|
||||
discovery.description_required: A description is required for discovery listing.
|
||||
discovery.disabled: Discovery isn't available on this instance.
|
||||
discovery.insufficient_members: This community doesn't meet the minimum member count for discovery.
|
||||
discovery.invalid_category: Invalid discovery category.
|
||||
discovery.not_discoverable: This community isn't listed in discovery.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidad ya aplicó al descubrimiento.
|
||||
discovery.application_already_reviewed: Esta solicitud de descubrimiento ya fue revisada.
|
||||
discovery.application_not_found: No se encontró la solicitud de descubrimiento.
|
||||
discovery.description_required: Se requiere una descripción para el listado en descubrimiento.
|
||||
discovery.disabled: El descubrimiento no está disponible en esta instancia.
|
||||
discovery.insufficient_members: Esta comunidad no cumple con el mínimo de miembros para el descubrimiento.
|
||||
discovery.invalid_category: Categoría de descubrimiento inválida.
|
||||
discovery.not_discoverable: Esta comunidad no está listada en descubrimiento.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidad ya ha solicitado ser listada en el dir
|
||||
discovery.application_already_reviewed: Esta solicitud de listado ya ha sido revisada.
|
||||
discovery.application_not_found: No se ha encontrado la solicitud de listado.
|
||||
discovery.description_required: Se requiere una descripción para el listado en el directorio.
|
||||
discovery.disabled: El descubrimiento no está disponible en esta instancia.
|
||||
discovery.insufficient_members: Esta comunidad no cumple el mínimo de miembros necesario para el directorio.
|
||||
discovery.invalid_category: Categoría de directorio no válida.
|
||||
discovery.not_discoverable: Esta comunidad no está listada en el directorio.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Tämä yhteisö on jo hakenut hakemistoon.
|
||||
discovery.application_already_reviewed: Tämä hakemistohakemus on jo käsitelty.
|
||||
discovery.application_not_found: Hakemistohakemusta ei löytynyt.
|
||||
discovery.description_required: Hakemistoluetteloa varten vaaditaan kuvaus.
|
||||
discovery.disabled: Haku ei ole saatavilla tässä instanssissa.
|
||||
discovery.insufficient_members: Tällä yhteisöllä ei ole tarpeeksi jäseniä hakemistoon.
|
||||
discovery.invalid_category: Virheellinen hakemistokategoria.
|
||||
discovery.not_discoverable: Tämä yhteisö ei ole hakemistossa.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Cette communauté a déjà postulé pour la découver
|
||||
discovery.application_already_reviewed: Cette candidature de découverte a déjà été examinée.
|
||||
discovery.application_not_found: La candidature de découverte est introuvable.
|
||||
discovery.description_required: Une description est requise pour le référencement dans la découverte.
|
||||
discovery.disabled: La découverte n'est pas disponible sur cette instance.
|
||||
discovery.insufficient_members: Cette communauté n'atteint pas le nombre minimum de membres requis pour la découverte.
|
||||
discovery.invalid_category: Catégorie de découverte invalide.
|
||||
discovery.not_discoverable: Cette communauté n'est pas référencée dans la découverte.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: הקהילה הזו כבר הגישה בקשה לגי
|
||||
discovery.application_already_reviewed: בקשת הגילוי הזו כבר נבדקה.
|
||||
discovery.application_not_found: בקשת הגילוי לא נמצאה.
|
||||
discovery.description_required: נדרש תיאור לרישום בגילוי.
|
||||
discovery.disabled: הגילוי אינו זמין בהופעה זו.
|
||||
discovery.insufficient_members: הקהילה הזו לא עומדת במספר המינימלי של חברים לגילוי.
|
||||
discovery.invalid_category: קטגוריית גילוי לא תקינה.
|
||||
discovery.not_discoverable: הקהילה הזו לא מופיעה ברשימת הגילוי.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: यह समुदाय पहले ही डि
|
||||
discovery.application_already_reviewed: इस डिस्कवरी आवेदन की पहले ही समीक्षा की जा चुकी है।
|
||||
discovery.application_not_found: डिस्कवरी आवेदन नहीं मिला।
|
||||
discovery.description_required: डिस्कवरी सूची के लिए विवरण जरूरी है।
|
||||
discovery.disabled: यह उदाहरण पर आविष्कार उपलब्ध नहीं है।
|
||||
discovery.insufficient_members: यह समुदाय डिस्कवरी के लिए न्यूनतम सदस्य संख्या की शर्त पूरी नहीं करता।
|
||||
discovery.invalid_category: डिस्कवरी श्रेणी अमान्य है।
|
||||
discovery.not_discoverable: यह समुदाय डिस्कवरी में सूचीबद्ध नहीं है।
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Ova zajednica se već prijavila za otkrivanje.
|
||||
discovery.application_already_reviewed: Ovaj zahtjev za otkrivanje je već pregledan.
|
||||
discovery.application_not_found: Zahtjev za otkrivanje nije pronađen.
|
||||
discovery.description_required: Opis je obavezan za popis u otkrivanju.
|
||||
discovery.disabled: Otkrivanje nije dostupno na ovoj instanci.
|
||||
discovery.insufficient_members: Ova zajednica ne ispunjava minimalan broj članova za otkrivanje.
|
||||
discovery.invalid_category: Neispravna kategorija otkrivanja.
|
||||
discovery.not_discoverable: Ova zajednica nije navedena u otkrivanju.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Ez a közösség már jelentkezett a felfedezésbe.
|
||||
discovery.application_already_reviewed: Ezt a felfedezési kérelmet már elbírálták.
|
||||
discovery.application_not_found: A felfedezési kérelem nem található.
|
||||
discovery.description_required: A felfedezési listázáshoz leírás szükséges.
|
||||
discovery.disabled: A felfedezés nem érhető el ezen a példányon.
|
||||
discovery.insufficient_members: Ez a közösség nem éri el a felfedezéshez szükséges minimális taglétszámot.
|
||||
discovery.invalid_category: Érvénytelen felfedezési kategória.
|
||||
discovery.not_discoverable: Ez a közösség nem szerepel a felfedezésben.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Komunitas ini sudah mengajukan permohonan untuk penem
|
||||
discovery.application_already_reviewed: Permohonan penemuan ini sudah ditinjau.
|
||||
discovery.application_not_found: Permohonan penemuan tidak ditemukan.
|
||||
discovery.description_required: Deskripsi wajib diisi untuk pendaftaran penemuan.
|
||||
discovery.disabled: Penemuan tidak tersedia di instans ini.
|
||||
discovery.insufficient_members: Komunitas ini tidak memenuhi jumlah anggota minimum untuk penemuan.
|
||||
discovery.invalid_category: Kategori penemuan tidak valid.
|
||||
discovery.not_discoverable: Komunitas ini tidak terdaftar dalam penemuan.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Questa comunità ha già fatto richiesta per la disco
|
||||
discovery.application_already_reviewed: Questa richiesta di discovery è già stata esaminata.
|
||||
discovery.application_not_found: Richiesta di discovery non trovata.
|
||||
discovery.description_required: È necessaria una descrizione per la scheda di discovery.
|
||||
discovery.disabled: La scoperta non è disponibile su questa istanza.
|
||||
discovery.insufficient_members: Questa comunità non soddisfa il numero minimo di membri per la discovery.
|
||||
discovery.invalid_category: Categoria di discovery non valida.
|
||||
discovery.not_discoverable: Questa comunità non è presente nella discovery.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: このコミュニティはすでにディスカバ
|
||||
discovery.application_already_reviewed: このディスカバリー申請はすでに審査済みだよ。
|
||||
discovery.application_not_found: ディスカバリー申請が見つからないよ。
|
||||
discovery.description_required: ディスカバリーの掲載には説明文が必要だよ。
|
||||
discovery.disabled: このインスタンスではDiscoveryを利用できません。
|
||||
discovery.insufficient_members: このコミュニティはディスカバリーに必要な最小メンバー数を満たしていないよ。
|
||||
discovery.invalid_category: ディスカバリーのカテゴリが無効だよ。
|
||||
discovery.not_discoverable: このコミュニティはディスカバリーに掲載されていないよ。
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: 이 커뮤니티는 이미 디스커버리에 신청
|
||||
discovery.application_already_reviewed: 이 디스커버리 신청서는 이미 검토됐어요.
|
||||
discovery.application_not_found: 디스커버리 신청서를 찾을 수 없어요.
|
||||
discovery.description_required: 디스커버리 등록에는 설명이 필요해요.
|
||||
discovery.disabled: 이 인스턴스에서는 발견을 사용할 수 없습니다.
|
||||
discovery.insufficient_members: 이 커뮤니티는 디스커버리에 필요한 최소 멤버 수를 충족하지 못해요.
|
||||
discovery.invalid_category: 디스커버리 카테고리가 올바르지 않아요.
|
||||
discovery.not_discoverable: 이 커뮤니티는 디스커버리에 등록돼 있지 않아요.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Ši bendruomenė jau kreipėsi dėl atradimų.
|
||||
discovery.application_already_reviewed: Ši atradimų paraiška jau peržiūrėta.
|
||||
discovery.application_not_found: Atradimų paraiška nerasta.
|
||||
discovery.description_required: Atradimų sąraše reikalingas aprašymas.
|
||||
discovery.disabled: Atradimas nėra pasiekiamas šioje egzemplioriuje.
|
||||
discovery.insufficient_members: Ši bendruomenė neatitinka minimalaus narių skaičiaus atradimams.
|
||||
discovery.invalid_category: Neteisinga atradimų kategorija.
|
||||
discovery.not_discoverable: Ši bendruomenė nėra įtraukta į atradimų sąrašą.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: This community has already applied for discovery.
|
||||
discovery.application_already_reviewed: This discovery application has already been reviewed.
|
||||
discovery.application_not_found: Discovery application wasn't found.
|
||||
discovery.description_required: A description is required for discovery listing.
|
||||
discovery.disabled: Discovery isn't available on this instance.
|
||||
discovery.insufficient_members: This community doesn't meet the minimum member count for discovery.
|
||||
discovery.invalid_category: Invalid discovery category.
|
||||
discovery.not_discoverable: This community isn't listed in discovery.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Deze community heeft al een aanvraag ingediend voor o
|
||||
discovery.application_already_reviewed: Deze ontdekkingsaanvraag is al beoordeeld.
|
||||
discovery.application_not_found: Ontdekkingsaanvraag is niet gevonden.
|
||||
discovery.description_required: Een beschrijving is verplicht voor de ontdekkingsvermelding.
|
||||
discovery.disabled: Ontdekking is niet beschikbaar op deze instantie.
|
||||
discovery.insufficient_members: Deze community voldoet niet aan het minimale ledenaantal voor ontdekking.
|
||||
discovery.invalid_category: Ongeldige ontdekkingscategorie.
|
||||
discovery.not_discoverable: Deze community staat niet vermeld in ontdekking.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Dette fellesskapet har allerede søkt om oppføring i
|
||||
discovery.application_already_reviewed: Denne oppdagelsessøknaden er allerede gjennomgått.
|
||||
discovery.application_not_found: Fant ikke oppdagelsessøknaden.
|
||||
discovery.description_required: En beskrivelse er påkrevd for oppføring i oppdagelse.
|
||||
discovery.disabled: Oppdagelse er ikke tilgjengelig på denne instansen.
|
||||
discovery.insufficient_members: Dette fellesskapet oppfyller ikke minimumskravet for antall medlemmer for oppdagelse.
|
||||
discovery.invalid_category: Ugyldig oppdagelseskategori.
|
||||
discovery.not_discoverable: Dette fellesskapet er ikke oppført i oppdagelse.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Ta społeczność złożyła już wniosek o odkrywaln
|
||||
discovery.application_already_reviewed: Ten wniosek o odkrywalność został już rozpatrzony.
|
||||
discovery.application_not_found: Nie znaleziono wniosku o odkrywalność.
|
||||
discovery.description_required: Opis jest wymagany do umieszczenia w odkrywalności.
|
||||
discovery.disabled: Odkrywanie nie jest dostępne na tym serwerze.
|
||||
discovery.insufficient_members: Ta społeczność nie spełnia minimalnej liczby członków wymaganej do odkrywalności.
|
||||
discovery.invalid_category: Nieprawidłowa kategoria odkrywalności.
|
||||
discovery.not_discoverable: Ta społeczność nie jest wymieniona w odkrywalności.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Esta comunidade já se candidatou à descoberta.
|
||||
discovery.application_already_reviewed: Esta candidatura de descoberta já foi analisada.
|
||||
discovery.application_not_found: A candidatura de descoberta não foi encontrada.
|
||||
discovery.description_required: É obrigatória uma descrição para a listagem de descoberta.
|
||||
discovery.disabled: A descoberta não está disponível nesta instância.
|
||||
discovery.insufficient_members: Esta comunidade não atinge o número mínimo de membros para a descoberta.
|
||||
discovery.invalid_category: Categoria de descoberta inválida.
|
||||
discovery.not_discoverable: Esta comunidade não está listada na descoberta.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Această comunitate a aplicat deja pentru descoperire
|
||||
discovery.application_already_reviewed: Această cerere de descoperire a fost deja analizată.
|
||||
discovery.application_not_found: Cererea de descoperire nu a fost găsită.
|
||||
discovery.description_required: O descriere este obligatorie pentru listarea în descoperire.
|
||||
discovery.disabled: Descoperirea nu este disponibilă pe această instanță.
|
||||
discovery.insufficient_members: Această comunitate nu întrunește numărul minim de membri pentru descoperire.
|
||||
discovery.invalid_category: Categorie de descoperire invalidă.
|
||||
discovery.not_discoverable: Această comunitate nu este listată în descoperire.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Это сообщество уже подало за
|
||||
discovery.application_already_reviewed: Эта заявка на участие в каталоге уже рассмотрена.
|
||||
discovery.application_not_found: Заявка на участие в каталоге не найдена.
|
||||
discovery.description_required: Для размещения в каталоге нужно добавить описание.
|
||||
discovery.disabled: Обнаружение недоступно на этом экземпляре.
|
||||
discovery.insufficient_members: В этом сообществе недостаточно участников для участия в каталоге.
|
||||
discovery.invalid_category: Недопустимая категория каталога.
|
||||
discovery.not_discoverable: Это сообщество не размещено в каталоге.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Den här gemenskapen har redan ansökt om discovery.
|
||||
discovery.application_already_reviewed: Den här discovery-ansökan har redan granskats.
|
||||
discovery.application_not_found: Discovery-ansökan hittades inte.
|
||||
discovery.description_required: En beskrivning krävs för att listas i discovery.
|
||||
discovery.disabled: Upptäckten är inte tillgänglig på denna instans.
|
||||
discovery.insufficient_members: Den här gemenskapen uppfyller inte minimikravet för antal medlemmar för discovery.
|
||||
discovery.invalid_category: Ogiltig discovery-kategori.
|
||||
discovery.not_discoverable: Den här gemenskapen är inte listad i discovery.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: ชุมชนนี้สมัครเพื่
|
||||
discovery.application_already_reviewed: ใบสมัครการค้นพบนี้ถูกตรวจสอบแล้ว
|
||||
discovery.application_not_found: ไม่พบใบสมัครการค้นพบ
|
||||
discovery.description_required: ต้องมีคำอธิบายสำหรับการลงรายการการค้นพบ
|
||||
discovery.disabled: ไม่มีการค้นพบบนอินสแตนซ์นี้
|
||||
discovery.insufficient_members: ชุมชนนี้มีจำนวนสมาชิกไม่ถึงขั้นต่ำที่กำหนดสำหรับการค้นพบ
|
||||
discovery.invalid_category: หมวดหมู่การค้นพบไม่ถูกต้อง
|
||||
discovery.not_discoverable: ชุมชนนี้ไม่ได้อยู่ในรายการการค้นพบ
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Bu topluluk keşif için zaten başvurmuş.
|
||||
discovery.application_already_reviewed: Bu keşif başvurusu zaten incelenmiş.
|
||||
discovery.application_not_found: Keşif başvurusu bulunamadı.
|
||||
discovery.description_required: Keşif listesi için bir açıklama gerekli.
|
||||
discovery.disabled: Keşif bu örnekte kullanılamaz.
|
||||
discovery.insufficient_members: Bu topluluk keşif için gereken minimum üye sayısını karşılamıyor.
|
||||
discovery.invalid_category: Geçersiz keşif kategorisi.
|
||||
discovery.not_discoverable: Bu topluluk keşifte listelenmemiş.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Ця спільнота вже подала заяв
|
||||
discovery.application_already_reviewed: Цю заявку на виявлення вже розглянуто.
|
||||
discovery.application_not_found: Заявку на виявлення не знайдено.
|
||||
discovery.description_required: Для виявлення в списку потрібен опис.
|
||||
discovery.disabled: Виявлення недоступне на цьому екземплярі.
|
||||
discovery.insufficient_members: Ця спільнота не відповідає мінімальній кількості учасників для виявлення.
|
||||
discovery.invalid_category: Недійсна категорія виявлення.
|
||||
discovery.not_discoverable: Ця спільнота не включена у виявлення.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: Cộng đồng này đã nộp đơn đăng ký khám
|
||||
discovery.application_already_reviewed: Đơn đăng ký khám phá này đã được xem xét rồi.
|
||||
discovery.application_not_found: Không tìm thấy đơn đăng ký khám phá.
|
||||
discovery.description_required: Cần có mô tả để đăng ký danh sách khám phá.
|
||||
discovery.disabled: Khám phá không có sẵn trên phiên bản này.
|
||||
discovery.insufficient_members: Cộng đồng này chưa đáp ứng số lượng thành viên tối thiểu để tham gia khám phá.
|
||||
discovery.invalid_category: Danh mục khám phá không hợp lệ.
|
||||
discovery.not_discoverable: Cộng đồng này không có trong danh sách khám phá.
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: 该社区已申请过发现功能。
|
||||
discovery.application_already_reviewed: 该发现申请已审核过了。
|
||||
discovery.application_not_found: 未找到发现申请。
|
||||
discovery.description_required: 发现列表需要填写简介。
|
||||
discovery.disabled: 此实例上不提供发现。
|
||||
discovery.insufficient_members: 该社区的成员数量不满足发现功能的最低要求。
|
||||
discovery.invalid_category: 发现分类无效。
|
||||
discovery.not_discoverable: 该社区未在发现中列出。
|
||||
|
||||
@@ -218,6 +218,7 @@ discovery.already_applied: 這個社群已經申請過探索功能了。
|
||||
discovery.application_already_reviewed: 這份探索申請已經審核過了。
|
||||
discovery.application_not_found: 找不到探索申請。
|
||||
discovery.description_required: 加入探索列表需要提供描述。
|
||||
discovery.disabled: 此實例上無法使用發現。
|
||||
discovery.insufficient_members: 這個社群的成員數量未達探索功能的最低要求。
|
||||
discovery.invalid_category: 探索分類無效。
|
||||
discovery.not_discoverable: 這個社群未列於探索列表中。
|
||||
|
||||
@@ -30,7 +30,6 @@ import {createMarketingContextFactory} from '@fluxer/marketing/src/app/Marketing
|
||||
import {applyMarketingMiddlewareStack} from '@fluxer/marketing/src/app/MarketingMiddlewareStack';
|
||||
import {registerMarketingRoutes} from '@fluxer/marketing/src/app/MarketingRouteRegistrar';
|
||||
import {applyMarketingStaticAssets} from '@fluxer/marketing/src/app/MarketingStaticAssets';
|
||||
import {createBadgeCache, productHuntFeaturedUrl, productHuntTopPostUrl} from '@fluxer/marketing/src/BadgeProxy';
|
||||
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
|
||||
import {createMarketingMetricsMiddleware} from '@fluxer/marketing/src/MarketingTelemetry';
|
||||
import {initializeMarketingCsrf} from '@fluxer/marketing/src/middleware/Csrf';
|
||||
@@ -59,14 +58,9 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
|
||||
const publicDir = resolve(publicDirOption ?? fileURLToPath(new URL('../public', import.meta.url)));
|
||||
const app = new Hono();
|
||||
|
||||
const badgeFeaturedCache = createBadgeCache(productHuntFeaturedUrl);
|
||||
const badgeTopPostCache = createBadgeCache(productHuntTopPostUrl);
|
||||
|
||||
const contextFactory = createMarketingContextFactory({
|
||||
config,
|
||||
publicDir,
|
||||
badgeFeaturedCache,
|
||||
badgeTopPostCache,
|
||||
});
|
||||
|
||||
initializeMarketingCsrf(config.secretKeyBase, config.env === 'production');
|
||||
@@ -93,8 +87,6 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
|
||||
app,
|
||||
config,
|
||||
contextFactory,
|
||||
badgeFeaturedCache,
|
||||
badgeTopPostCache,
|
||||
});
|
||||
|
||||
const shutdown = (): void => {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {readMarketingResponseAsText, sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
|
||||
import type {Context} from 'hono';
|
||||
|
||||
export const productHuntFeaturedUrl =
|
||||
'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light';
|
||||
export const productHuntTopPostUrl =
|
||||
'https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613';
|
||||
|
||||
const staleAfterMs = 300_000;
|
||||
const fetchTimeoutMs = 4_500;
|
||||
|
||||
export interface BadgeCache {
|
||||
getBadge(): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
svg: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export function createBadgeCache(url: string): BadgeCache {
|
||||
let cache: CacheEntry | null = null;
|
||||
let isRefreshing = false;
|
||||
|
||||
async function refreshBadge(): Promise<void> {
|
||||
if (isRefreshing) return;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const svg = await fetchBadgeSvg(url);
|
||||
if (svg) {
|
||||
cache = {svg, fetchedAt: Date.now()};
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getBadge() {
|
||||
const now = Date.now();
|
||||
if (!cache) {
|
||||
const svg = await fetchBadgeSvg(url);
|
||||
if (svg) {
|
||||
cache = {svg, fetchedAt: now};
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
const isStale = now - cache.fetchedAt > staleAfterMs;
|
||||
if (isStale) {
|
||||
void refreshBadge();
|
||||
}
|
||||
return cache.svg;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBadgeResponse(cache: BadgeCache, c: Context): Promise<Response> {
|
||||
const svg = await cache.getBadge();
|
||||
if (!svg) {
|
||||
c.header('content-type', 'text/plain');
|
||||
c.header('retry-after', '60');
|
||||
return c.text('Badge temporarily unavailable', 503);
|
||||
}
|
||||
|
||||
c.header('content-type', 'image/svg+xml');
|
||||
c.header('cache-control', 'public, max-age=300, stale-while-revalidate=600');
|
||||
c.header('vary', 'Accept');
|
||||
return c.body(svg, 200);
|
||||
}
|
||||
|
||||
async function fetchBadgeSvg(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await sendMarketingRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'image/svg+xml',
|
||||
},
|
||||
timeout: fetchTimeoutMs,
|
||||
serviceName: 'marketing_badges',
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) return null;
|
||||
return await readMarketingResponseAsText(response.stream);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import type {LocaleCode} from '@fluxer/constants/src/Locales';
|
||||
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
|
||||
import type {MarketingI18nService} from '@fluxer/marketing/src/marketing_i18n/MarketingI18nService';
|
||||
|
||||
export type MarketingPlatform = 'windows' | 'macos' | 'linux' | 'ios' | 'android' | 'unknown';
|
||||
@@ -40,8 +39,6 @@ export interface MarketingContext {
|
||||
platform: MarketingPlatform;
|
||||
architecture: MarketingArchitecture;
|
||||
releaseChannel: string;
|
||||
badgeFeaturedCache: BadgeCache;
|
||||
badgeTopPostCache: BadgeCache;
|
||||
csrfToken: string;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
|
||||
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
|
||||
import {createI18n} from '@fluxer/marketing/src/I18n';
|
||||
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
|
||||
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
|
||||
@@ -33,8 +32,6 @@ import type {Context as HonoContext} from 'hono';
|
||||
export interface CreateMarketingContextFactoryOptions {
|
||||
config: MarketingConfig;
|
||||
publicDir: string;
|
||||
badgeFeaturedCache: BadgeCache;
|
||||
badgeTopPostCache: BadgeCache;
|
||||
}
|
||||
|
||||
export type MarketingContextFactory = (c: HonoContext) => Promise<MarketingContext>;
|
||||
@@ -62,8 +59,6 @@ export function createMarketingContextFactory(options: CreateMarketingContextFac
|
||||
platform: requestInfo.platform,
|
||||
architecture: requestInfo.architecture,
|
||||
releaseChannel: options.config.releaseChannel,
|
||||
badgeFeaturedCache: options.badgeFeaturedCache,
|
||||
badgeTopPostCache: options.badgeTopPostCache,
|
||||
csrfToken,
|
||||
isDev: options.config.env === 'development',
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ import {HttpStatus, MimeType} from '@fluxer/constants/src/HttpConstants';
|
||||
import {isPressAssetId, PressAssets} from '@fluxer/constants/src/PressAssets';
|
||||
import {createSession} from '@fluxer/hono/src/Session';
|
||||
import {getLocaleFromCode} from '@fluxer/locale/src/LocaleService';
|
||||
import {type BadgeCache, createBadgeResponse} from '@fluxer/marketing/src/BadgeProxy';
|
||||
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
|
||||
import {sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
|
||||
import {renderCareersPage} from '@fluxer/marketing/src/pages/CareersPage';
|
||||
@@ -54,8 +53,6 @@ export interface RegisterMarketingRoutesOptions {
|
||||
app: Hono;
|
||||
config: MarketingConfig;
|
||||
contextFactory: MarketingContextFactory;
|
||||
badgeFeaturedCache: BadgeCache;
|
||||
badgeTopPostCache: BadgeCache;
|
||||
}
|
||||
|
||||
interface LocaleCookieSession {
|
||||
@@ -88,7 +85,6 @@ const PAGE_ROUTE_DEFINITIONS: ReadonlyArray<{
|
||||
];
|
||||
|
||||
export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions): void {
|
||||
registerBadgeRoutes(options.app, options.badgeFeaturedCache, options.badgeTopPostCache);
|
||||
registerLocaleRoute(options.app, options.config);
|
||||
registerExternalRedirects(options.app);
|
||||
registerSystemContentRoutes(options.app, options.contextFactory);
|
||||
@@ -99,16 +95,6 @@ export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions)
|
||||
registerNotFoundRoute(options.app, options.contextFactory);
|
||||
}
|
||||
|
||||
function registerBadgeRoutes(app: Hono, badgeFeaturedCache: BadgeCache, badgeTopPostCache: BadgeCache): void {
|
||||
app.get('/api/badges/product-hunt', async (c) => {
|
||||
return await createBadgeResponse(badgeFeaturedCache, c);
|
||||
});
|
||||
|
||||
app.get('/api/badges/product-hunt-top-post', async (c) => {
|
||||
return await createBadgeResponse(badgeTopPostCache, c);
|
||||
});
|
||||
}
|
||||
|
||||
function registerLocaleRoute(app: Hono, config: MarketingConfig): void {
|
||||
app.post('/_locale', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
|
||||
@@ -20,11 +20,12 @@
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {Locales} from '@fluxer/constants/src/Locales';
|
||||
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
|
||||
import {HackernewsBanner} from '@fluxer/marketing/src/components/HackernewsBanner';
|
||||
import {MadeInSwedenBadge} from '@fluxer/marketing/src/components/MadeInSwedenBadge';
|
||||
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
|
||||
import {renderSecondaryButton, renderWithOverlay} from '@fluxer/marketing/src/components/PlatformDownloadButton';
|
||||
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
|
||||
import {href} from '@fluxer/marketing/src/UrlUtils';
|
||||
|
||||
interface HeroProps {
|
||||
ctx: MarketingContext;
|
||||
@@ -41,13 +42,35 @@ export function Hero(props: HeroProps): JSX.Element {
|
||||
<span class="font-bold text-3xl text-white">Fluxer(フラクサー)</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div class="flex flex-wrap justify-center gap-3 pb-2">
|
||||
<span class="rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
|
||||
{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}
|
||||
</span>
|
||||
<MadeInSwedenBadge ctx={ctx} />
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 pb-2">
|
||||
<a
|
||||
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
{ctx.i18n.getMessage('launch.heading', ctx.locale)}
|
||||
<ArrowRightIcon class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://blog.fluxer.app/roadmap-2026"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
|
||||
>
|
||||
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
|
||||
<ArrowRightIcon class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="hero">{ctx.i18n.getMessage('general.tagline', ctx.locale)}</h1>
|
||||
<div class="-mt-4 flex items-center justify-center gap-2 font-medium text-sm text-white/80">
|
||||
<span>{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}</span>
|
||||
<span class="text-white/40">·</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-3.5 w-3.5 rounded-sm" />
|
||||
{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="lead mx-auto max-w-2xl text-white/90">
|
||||
{ctx.i18n.getMessage('product_positioning.intro', ctx.locale)}
|
||||
</p>
|
||||
@@ -105,32 +128,6 @@ export function Hero(props: HeroProps): JSX.Element {
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-4 md:mt-12">
|
||||
<a
|
||||
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
alt="Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt"
|
||||
width="250"
|
||||
height="54"
|
||||
src={href(ctx, '/api/badges/product-hunt')}
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
alt={ctx.i18n.getMessage('misc_labels.product_hunt_badge_title', ctx.locale)}
|
||||
width="250"
|
||||
height="54"
|
||||
src={href(ctx, '/api/badges/product-hunt-top-post')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
|
||||
import {MarketingButton, MarketingButtonSecondary} from '@fluxer/marketing/src/components/MarketingButton';
|
||||
import {Section} from '@fluxer/marketing/src/components/Section';
|
||||
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
|
||||
|
||||
interface LaunchBlogSectionProps {
|
||||
ctx: MarketingContext;
|
||||
}
|
||||
|
||||
export function LaunchBlogSection(props: LaunchBlogSectionProps): JSX.Element {
|
||||
const {ctx} = props;
|
||||
|
||||
return (
|
||||
<Section
|
||||
variant="light"
|
||||
title={ctx.i18n.getMessage('launch.heading', ctx.locale)}
|
||||
description={ctx.i18n.getMessage('launch.description', ctx.locale)}
|
||||
>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:items-stretch">
|
||||
<MarketingButton
|
||||
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
|
||||
size="large"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 md:text-xl"
|
||||
>
|
||||
{ctx.i18n.getMessage('launch.read_more', ctx.locale)}
|
||||
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
|
||||
</MarketingButton>
|
||||
<MarketingButtonSecondary
|
||||
href="https://blog.fluxer.app/roadmap-2026"
|
||||
size="large"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="md:text-xl"
|
||||
>
|
||||
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
|
||||
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
|
||||
</MarketingButtonSecondary>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {CurrentFeaturesSection} from '@fluxer/marketing/src/components/CurrentFe
|
||||
import {FinalCtaSection} from '@fluxer/marketing/src/components/FinalCtaSection';
|
||||
import {GetInvolvedSection} from '@fluxer/marketing/src/components/GetInvolvedSection';
|
||||
import {Hero} from '@fluxer/marketing/src/components/Hero';
|
||||
import {LaunchBlogSection} from '@fluxer/marketing/src/components/LaunchBlogSection';
|
||||
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
|
||||
import {renderLayout} from '@fluxer/marketing/src/pages/Layout';
|
||||
import {defaultPageMeta} from '@fluxer/marketing/src/pages/layout/Meta';
|
||||
@@ -34,7 +33,6 @@ export async function renderHomePage(c: Context, ctx: MarketingContext): Promise
|
||||
const getInvolved = await GetInvolvedSection({ctx});
|
||||
const content: ReadonlyArray<JSX.Element> = [
|
||||
<Hero ctx={ctx} />,
|
||||
<LaunchBlogSection ctx={ctx} />,
|
||||
<CurrentFeaturesSection ctx={ctx} />,
|
||||
getInvolved,
|
||||
<FinalCtaSection ctx={ctx} />,
|
||||
|
||||
@@ -30,7 +30,7 @@ export const DiscoveryApplicationRequest = z.object({
|
||||
.min(DISCOVERY_DESCRIPTION_MIN_LENGTH)
|
||||
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
|
||||
.describe('Description for discovery listing'),
|
||||
category_id: z.number().int().min(0).max(8).describe('Discovery category ID'),
|
||||
category_type: z.number().int().min(0).max(8).describe('Discovery category type'),
|
||||
});
|
||||
|
||||
export type DiscoveryApplicationRequest = z.infer<typeof DiscoveryApplicationRequest>;
|
||||
@@ -42,7 +42,7 @@ export const DiscoveryApplicationPatchRequest = z.object({
|
||||
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
|
||||
.optional()
|
||||
.describe('Updated description for discovery listing'),
|
||||
category_id: z.number().int().min(0).max(8).optional().describe('Updated discovery category ID'),
|
||||
category_type: z.number().int().min(0).max(8).optional().describe('Updated discovery category type'),
|
||||
});
|
||||
|
||||
export type DiscoveryApplicationPatchRequest = z.infer<typeof DiscoveryApplicationPatchRequest>;
|
||||
@@ -62,7 +62,7 @@ export const DiscoveryGuildResponse = z.object({
|
||||
name: z.string().describe('Guild name'),
|
||||
icon: z.string().nullish().describe('Guild icon hash'),
|
||||
description: z.string().nullish().describe('Discovery description'),
|
||||
category_id: z.number().describe('Discovery category ID'),
|
||||
category_type: z.number().describe('Discovery category type'),
|
||||
member_count: z.number().describe('Approximate member count'),
|
||||
online_count: z.number().describe('Approximate online member count'),
|
||||
features: z.array(z.string()).describe('Guild feature flags'),
|
||||
@@ -82,7 +82,7 @@ export const DiscoveryApplicationResponse = z.object({
|
||||
guild_id: SnowflakeStringType.describe('Guild ID'),
|
||||
status: z.string().describe('Application status'),
|
||||
description: z.string().describe('Discovery description'),
|
||||
category_id: z.number().describe('Discovery category ID'),
|
||||
category_type: z.number().describe('Discovery category type'),
|
||||
applied_at: z.string().describe('Application timestamp'),
|
||||
reviewed_at: z.string().nullish().describe('Review timestamp'),
|
||||
review_reason: z.string().nullish().describe('Review reason'),
|
||||
@@ -90,6 +90,14 @@ export const DiscoveryApplicationResponse = z.object({
|
||||
|
||||
export type DiscoveryApplicationResponse = z.infer<typeof DiscoveryApplicationResponse>;
|
||||
|
||||
export const DiscoveryStatusResponse = z.object({
|
||||
application: DiscoveryApplicationResponse.nullish().describe('Current discovery application, if any'),
|
||||
eligible: z.boolean().describe('Whether the guild meets the requirements to apply for discovery'),
|
||||
min_member_count: z.number().describe('Minimum member count required for discovery eligibility'),
|
||||
});
|
||||
|
||||
export type DiscoveryStatusResponse = z.infer<typeof DiscoveryStatusResponse>;
|
||||
|
||||
export const DiscoveryCategoryResponse = z.object({
|
||||
id: z.number().describe('Category ID'),
|
||||
name: z.string().describe('Category display name'),
|
||||
|
||||
Reference in New Issue
Block a user