feat: dashboard

This commit is contained in:
2026-06-05 01:25:16 +02:00
parent 147858de2f
commit 2f6c52e73c
73 changed files with 3490 additions and 1061 deletions

View File

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

View 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)));
}

View 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)));
}

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

View 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;
}

View File

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

View File

@@ -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'

View File

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