feat: dashboard
This commit is contained in:
@@ -15,6 +15,8 @@ import { handleDeleteStaleJoinRequests } from './commands/handlers/deleteStaleJo
|
||||
import { handleGetUserByUsername } from './queries/handlers/getUserByUsername';
|
||||
import { handleGetUserById } from './queries/handlers/getUserById';
|
||||
import { handleGetAllPublicServers } from './queries/handlers/getAllPublicServers';
|
||||
import { handleGetFeaturedServers } from './queries/handlers/getFeaturedServers';
|
||||
import { handleGetTrendingServers } from './queries/handlers/getTrendingServers';
|
||||
import { handleGetServerById } from './queries/handlers/getServerById';
|
||||
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
||||
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
||||
@@ -46,6 +48,12 @@ export const getUserById = (userId: string) =>
|
||||
export const getAllPublicServers = () =>
|
||||
handleGetAllPublicServers(getDataSource());
|
||||
|
||||
export const getFeaturedServers = (limit?: number) =>
|
||||
handleGetFeaturedServers(getDataSource(), limit);
|
||||
|
||||
export const getTrendingServers = (limit?: number) =>
|
||||
handleGetTrendingServers(getDataSource(), limit);
|
||||
|
||||
export const getServerById = (serverId: string) =>
|
||||
handleGetServerById({ type: QueryType.GetServerById, payload: { serverId } }, getDataSource());
|
||||
|
||||
|
||||
16
server/src/cqrs/queries/handlers/getFeaturedServers.ts
Normal file
16
server/src/cqrs/queries/handlers/getFeaturedServers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
import { loadMembershipCounts, rankFeaturedServers } from './server-ranking.util';
|
||||
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
export async function handleGetFeaturedServers(dataSource: DataSource, limit = DEFAULT_LIMIT) {
|
||||
const rows = await dataSource.getRepository(ServerEntity).find({ where: { isPrivate: 0 } });
|
||||
const counts = await loadMembershipCounts(dataSource, rows.map((row) => row.id));
|
||||
const ranked = rankFeaturedServers(rows, counts, limit);
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, ranked.map((row) => row.id));
|
||||
|
||||
return ranked.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
}
|
||||
16
server/src/cqrs/queries/handlers/getTrendingServers.ts
Normal file
16
server/src/cqrs/queries/handlers/getTrendingServers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
import { loadMembershipCounts, rankTrendingServers } from './server-ranking.util';
|
||||
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
export async function handleGetTrendingServers(dataSource: DataSource, limit = DEFAULT_LIMIT) {
|
||||
const rows = await dataSource.getRepository(ServerEntity).find({ where: { isPrivate: 0 } });
|
||||
const counts = await loadMembershipCounts(dataSource, rows.map((row) => row.id));
|
||||
const ranked = rankTrendingServers(rows, counts, limit);
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, ranked.map((row) => row.id));
|
||||
|
||||
return ranked.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
}
|
||||
142
server/src/cqrs/queries/handlers/server-ranking.util.spec.ts
Normal file
142
server/src/cqrs/queries/handlers/server-ranking.util.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import {
|
||||
rankFeaturedServers,
|
||||
rankTrendingServers,
|
||||
RankableServer
|
||||
} from './server-ranking.util';
|
||||
|
||||
function server(id: string, lastSeen: number): RankableServer {
|
||||
return { id, lastSeen };
|
||||
}
|
||||
|
||||
describe('rankFeaturedServers', () => {
|
||||
it('orders by membership count descending', () => {
|
||||
const rows = [
|
||||
server('a', 100),
|
||||
server('b', 100),
|
||||
server('c', 100)
|
||||
];
|
||||
const counts = new Map([
|
||||
['a', 2],
|
||||
['b', 10],
|
||||
['c', 5]
|
||||
]);
|
||||
const ranked = rankFeaturedServers(rows, counts);
|
||||
|
||||
expect(ranked.map((row) => row.id)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks membership ties by most recent activity', () => {
|
||||
const rows = [
|
||||
server('a', 50),
|
||||
server('b', 200),
|
||||
server('c', 120)
|
||||
];
|
||||
const counts = new Map([
|
||||
['a', 5],
|
||||
['b', 5],
|
||||
['c', 5]
|
||||
]);
|
||||
const ranked = rankFeaturedServers(rows, counts);
|
||||
|
||||
expect(ranked.map((row) => row.id)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats missing counts as zero', () => {
|
||||
const rows = [server('a', 10), server('b', 10)];
|
||||
const counts = new Map([['a', 1]]);
|
||||
const ranked = rankFeaturedServers(rows, counts);
|
||||
|
||||
expect(ranked.map((row) => row.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('applies the limit', () => {
|
||||
const rows = [
|
||||
server('a', 1),
|
||||
server('b', 2),
|
||||
server('c', 3)
|
||||
];
|
||||
const counts = new Map([
|
||||
['a', 3],
|
||||
['b', 2],
|
||||
['c', 1]
|
||||
]);
|
||||
|
||||
expect(rankFeaturedServers(rows, counts, 2).map((row) => row.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns an empty array for a non-positive limit', () => {
|
||||
const rows = [server('a', 1)];
|
||||
|
||||
expect(rankFeaturedServers(rows, new Map(), 0)).toEqual([]);
|
||||
expect(rankFeaturedServers(rows, new Map(), -5)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate the input rows', () => {
|
||||
const rows = [server('a', 1), server('b', 2)];
|
||||
|
||||
rankFeaturedServers(rows, new Map([['b', 9]]));
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankTrendingServers', () => {
|
||||
it('orders by most recent activity descending', () => {
|
||||
const rows = [
|
||||
server('a', 100),
|
||||
server('b', 300),
|
||||
server('c', 200)
|
||||
];
|
||||
const counts = new Map<string, number>();
|
||||
const ranked = rankTrendingServers(rows, counts);
|
||||
|
||||
expect(ranked.map((row) => row.id)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks activity ties by membership count', () => {
|
||||
const rows = [
|
||||
server('a', 100),
|
||||
server('b', 100),
|
||||
server('c', 100)
|
||||
];
|
||||
const counts = new Map([
|
||||
['a', 1],
|
||||
['b', 9],
|
||||
['c', 4]
|
||||
]);
|
||||
const ranked = rankTrendingServers(rows, counts);
|
||||
|
||||
expect(ranked.map((row) => row.id)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies the limit', () => {
|
||||
const rows = [
|
||||
server('a', 1),
|
||||
server('b', 2),
|
||||
server('c', 3)
|
||||
];
|
||||
|
||||
expect(rankTrendingServers(rows, new Map(), 1).map((row) => row.id)).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
94
server/src/cqrs/queries/handlers/server-ranking.util.ts
Normal file
94
server/src/cqrs/queries/handlers/server-ranking.util.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerMembershipEntity } from '../../../entities';
|
||||
|
||||
export interface RankableServer {
|
||||
id: string;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor(limit);
|
||||
}
|
||||
|
||||
function membershipCount(counts: Map<string, number>, id: string): number {
|
||||
return counts.get(id) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured = most-populated public servers. Ranks by membership count descending,
|
||||
* breaking ties by most recent activity (`lastSeen`).
|
||||
*/
|
||||
export function rankFeaturedServers<T extends RankableServer>(
|
||||
rows: readonly T[],
|
||||
counts: Map<string, number>,
|
||||
limit: number = DEFAULT_LIMIT
|
||||
): T[] {
|
||||
return [...rows]
|
||||
.sort((first, second) => {
|
||||
const countDelta = membershipCount(counts, second.id) - membershipCount(counts, first.id);
|
||||
|
||||
if (countDelta !== 0) {
|
||||
return countDelta;
|
||||
}
|
||||
|
||||
return (second.lastSeen ?? 0) - (first.lastSeen ?? 0);
|
||||
})
|
||||
.slice(0, clampLimit(limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending = recently-active public servers. Ranks by most recent activity
|
||||
* (`lastSeen`) descending, breaking ties by membership count.
|
||||
*/
|
||||
export function rankTrendingServers<T extends RankableServer>(
|
||||
rows: readonly T[],
|
||||
counts: Map<string, number>,
|
||||
limit: number = DEFAULT_LIMIT
|
||||
): T[] {
|
||||
return [...rows]
|
||||
.sort((first, second) => {
|
||||
const activityDelta = (second.lastSeen ?? 0) - (first.lastSeen ?? 0);
|
||||
|
||||
if (activityDelta !== 0) {
|
||||
return activityDelta;
|
||||
}
|
||||
|
||||
return membershipCount(counts, second.id) - membershipCount(counts, first.id);
|
||||
})
|
||||
.slice(0, clampLimit(limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads membership counts for the supplied server ids in a single grouped query.
|
||||
*/
|
||||
export async function loadMembershipCounts(
|
||||
dataSource: DataSource,
|
||||
serverIds: readonly string[]
|
||||
): Promise<Map<string, number>> {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
const rows = await dataSource
|
||||
.getRepository(ServerMembershipEntity)
|
||||
.createQueryBuilder('membership')
|
||||
.select('membership.serverId', 'serverId')
|
||||
.addSelect('COUNT(membership.id)', 'count')
|
||||
.where('membership.serverId IN (:...serverIds)', { serverIds: [...serverIds] })
|
||||
.groupBy('membership.serverId')
|
||||
.getRawMany<{ serverId: string; count: string | number }>();
|
||||
|
||||
for (const row of rows) {
|
||||
counts.set(row.serverId, Number(row.count) || 0);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { handleGetUserByUsername } from './handlers/getUserByUsername';
|
||||
import { handleGetUserById } from './handlers/getUserById';
|
||||
import { handleGetAllPublicServers } from './handlers/getAllPublicServers';
|
||||
import { handleGetFeaturedServers } from './handlers/getFeaturedServers';
|
||||
import { handleGetTrendingServers } from './handlers/getTrendingServers';
|
||||
import { handleGetServerById } from './handlers/getServerById';
|
||||
import { handleGetJoinRequestById } from './handlers/getJoinRequestById';
|
||||
import { handleGetPendingRequestsForServer } from './handlers/getPendingRequestsForServer';
|
||||
@@ -20,6 +22,8 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
|
||||
[QueryType.GetUserByUsername]: (query) => handleGetUserByUsername(query as GetUserByUsernameQuery, dataSource),
|
||||
[QueryType.GetUserById]: (query) => handleGetUserById(query as GetUserByIdQuery, dataSource),
|
||||
[QueryType.GetAllPublicServers]: () => handleGetAllPublicServers(dataSource),
|
||||
[QueryType.GetFeaturedServers]: () => handleGetFeaturedServers(dataSource),
|
||||
[QueryType.GetTrendingServers]: () => handleGetTrendingServers(dataSource),
|
||||
[QueryType.GetServerById]: (query) => handleGetServerById(query as GetServerByIdQuery, dataSource),
|
||||
[QueryType.GetJoinRequestById]: (query) => handleGetJoinRequestById(query as GetJoinRequestByIdQuery, dataSource),
|
||||
[QueryType.GetPendingRequestsForServer]: (query) => handleGetPendingRequestsForServer(query as GetPendingRequestsForServerQuery, dataSource)
|
||||
|
||||
@@ -13,6 +13,8 @@ export const QueryType = {
|
||||
GetUserByUsername: 'get-user-by-username',
|
||||
GetUserById: 'get-user-by-id',
|
||||
GetAllPublicServers: 'get-all-public-servers',
|
||||
GetFeaturedServers: 'get-featured-servers',
|
||||
GetTrendingServers: 'get-trending-servers',
|
||||
GetServerById: 'get-server-by-id',
|
||||
GetJoinRequestById: 'get-join-request-by-id',
|
||||
GetPendingRequestsForServer: 'get-pending-requests-for-server'
|
||||
|
||||
@@ -3,6 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getFeaturedServers,
|
||||
getTrendingServers,
|
||||
getServerById,
|
||||
getUserById,
|
||||
upsertServer,
|
||||
@@ -155,6 +157,34 @@ router.get('/', async (req, res) => {
|
||||
res.json({ servers: enrichedResults, total, limit: Number(limit), offset: Number(offset) });
|
||||
});
|
||||
|
||||
function parseDiscoveryLimit(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(Math.floor(parsed), 50);
|
||||
}
|
||||
|
||||
// NOTE: `/featured` and `/trending` must be registered before `/:id`,
|
||||
// otherwise Express resolves them as a server id.
|
||||
router.get('/featured', async (req, res) => {
|
||||
const limit = parseDiscoveryLimit(req.query.limit, 12);
|
||||
const servers = await getFeaturedServers(limit);
|
||||
const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server)));
|
||||
|
||||
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
|
||||
});
|
||||
|
||||
router.get('/trending', async (req, res) => {
|
||||
const limit = parseDiscoveryLimit(req.query.limit, 12);
|
||||
const servers = await getTrendingServers(limit);
|
||||
const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server)));
|
||||
|
||||
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const {
|
||||
id: clientId,
|
||||
|
||||
Reference in New Issue
Block a user