185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
const modulename = 'WebServer:PlayersTableSearch';
|
|
import { PlayersTablePlayerType, PlayersTableSearchResp } from '@shared/playerApiTypes';
|
|
import { DatabasePlayerType } from '@modules/Database/databaseTypes';
|
|
import consoleFactory from '@lib/console';
|
|
import { AuthedCtx } from '@modules/WebServer/ctxTypes';
|
|
import cleanPlayerName from '@shared/cleanPlayerName';
|
|
import { chain as createChain } from 'lodash-es';
|
|
import Fuse from 'fuse.js';
|
|
import { parseLaxIdsArrayInput } from '@lib/player/idUtils';
|
|
import { TimeCounter } from '@modules/Metrics/statsUtils';
|
|
const console = consoleFactory(modulename);
|
|
|
|
//Helpers
|
|
const DEFAULT_LIMIT = 100; //cant override it for now
|
|
const ALLOWED_SORTINGS = ['playTime', 'tsJoined', 'tsLastConnection'];
|
|
const SIMPLE_FILTERS = ['isAdmin', 'isOnline', 'isWhitelisted', 'hasNote'];
|
|
//'isBanned', 'hasPreviousBan'
|
|
|
|
|
|
/**
|
|
* Returns the players stats for the Players page table
|
|
*/
|
|
export default async function PlayerSearch(ctx: AuthedCtx) {
|
|
//Sanity check
|
|
if (typeof ctx.query === 'undefined') {
|
|
return ctx.utils.error(400, 'Invalid Request');
|
|
}
|
|
const {
|
|
searchValue,
|
|
searchType,
|
|
filters,
|
|
sortingKey,
|
|
sortingDesc,
|
|
offsetParam,
|
|
offsetLicense
|
|
} = ctx.query;
|
|
const sendTypedResp = (data: PlayersTableSearchResp) => ctx.send(data);
|
|
const searchTime = new TimeCounter();
|
|
const adminsIdentifiers = txCore.adminStore.getAdminsIdentifiers();
|
|
const onlinePlayersLicenses = txCore.fxPlayerlist.getOnlinePlayersLicenses();
|
|
const dbo = txCore.database.getDboRef();
|
|
let chain = dbo.chain.get('players').clone(); //shallow clone to avoid sorting the original
|
|
/*
|
|
In order:
|
|
- [X] sort the players by the sortingKey/sortingDesc
|
|
- [x] filter the players by the simple filters (lightweight)
|
|
- [x] offset the players by the offsetParam/offsetLicense
|
|
- [x] filter the players by the searchValue/searchType (VERY HEAVY!)
|
|
- [ ] reduce actions table to get info on currently filtered players
|
|
- [ ] filter players by isBanned & hasPreviousBan
|
|
- [x] filter players by the limit
|
|
- [x] process the result and (no {isBanned, warnCount, banCount} for now)
|
|
- [x] return the result
|
|
*/
|
|
|
|
|
|
//sort the players by the sortingKey/sortingDesc
|
|
const parsedSortingDesc = sortingDesc === 'true';
|
|
if (typeof sortingKey !== 'string' || !ALLOWED_SORTINGS.includes(sortingKey)) {
|
|
return sendTypedResp({ error: 'Invalid sorting key' });
|
|
}
|
|
chain = chain.sort((a, b) => {
|
|
// @ts-ignore
|
|
return parsedSortingDesc ? b[sortingKey] - a[sortingKey] : a[sortingKey] - b[sortingKey];
|
|
});
|
|
|
|
|
|
//filter the players by the simple filters (lightweight)
|
|
if (typeof filters === 'string' && filters.length) {
|
|
const validRequestedFilters = new Set(filters.split(',').filter((x) => SIMPLE_FILTERS.includes(x)));
|
|
if (validRequestedFilters.size) {
|
|
const playerFilterFunctions = {
|
|
'isAdmin': (p: DatabasePlayerType) => p.ids.some((id) => adminsIdentifiers.includes(id)),
|
|
'isOnline': (p: DatabasePlayerType) => onlinePlayersLicenses.has(p.license),
|
|
'isWhitelisted': (p: DatabasePlayerType) => p.tsWhitelisted,
|
|
'hasNote': (p: DatabasePlayerType) => p.notes,
|
|
};
|
|
chain = chain.filter((p) => {
|
|
for (const filterName of validRequestedFilters) {
|
|
if (!playerFilterFunctions[filterName as keyof typeof playerFilterFunctions](p)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
//offset the players by the offsetParam/offsetLicense
|
|
if (offsetParam !== undefined && offsetLicense !== undefined) {
|
|
const parsedOffsetParam = parseInt(offsetParam as string);
|
|
if (isNaN(parsedOffsetParam) || typeof offsetLicense !== 'string' || !offsetLicense.length) {
|
|
return sendTypedResp({ error: 'Invalid offsetParam or offsetLicense' });
|
|
}
|
|
chain = chain.takeRightWhile((p) => {
|
|
return p.license !== offsetLicense && parsedSortingDesc
|
|
? p[sortingKey as keyof DatabasePlayerType] as number <= parsedOffsetParam
|
|
: p[sortingKey as keyof DatabasePlayerType] as number >= parsedOffsetParam
|
|
});
|
|
}
|
|
|
|
|
|
// filter the players by the searchValue/searchType (VERY HEAVY!)
|
|
if (typeof searchType === 'string') {
|
|
if (typeof searchValue !== 'string' || !searchValue.length) {
|
|
return sendTypedResp({ error: 'Invalid searchValue' });
|
|
}
|
|
|
|
if (searchType === 'playerName') {
|
|
//Searching by player name
|
|
const { pureName } = cleanPlayerName(searchValue);
|
|
if (pureName === 'emptyname') {
|
|
return sendTypedResp({ error: 'This player name is unsearchable (pureName is empty).' });
|
|
}
|
|
const players = chain.value();
|
|
const fuse = new Fuse(players, {
|
|
isCaseSensitive: true, //maybe that's an optimization?!
|
|
keys: ['pureName'],
|
|
threshold: 0.3
|
|
});
|
|
const filtered = fuse.search(pureName).map(x => x.item);
|
|
chain = createChain(filtered);
|
|
} else if (searchType === 'playerNotes') {
|
|
//Searching by player notes
|
|
const players = chain.value();
|
|
const fuse = new Fuse(players, {
|
|
keys: ['notes.text'],
|
|
threshold: 0.3
|
|
});
|
|
const filtered = fuse.search(searchValue).map(x => x.item);
|
|
chain = createChain(filtered);
|
|
} else if (searchType === 'playerIds') {
|
|
//Searching by player identifiers
|
|
const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue);
|
|
if (invalids.length) {
|
|
return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` });
|
|
}
|
|
if (!validIds.length && !validHwids.length) {
|
|
return sendTypedResp({ error: `No valid identifiers found.` });
|
|
}
|
|
chain = chain.filter((p) => {
|
|
if (validIds.length && !validIds.some((id) => p.ids.includes(id))) {
|
|
return false;
|
|
}
|
|
if (validHwids.length && !validHwids.some((hwid) => p.hwids.includes(hwid))) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
} else {
|
|
return sendTypedResp({ error: 'Unknown searchType' });
|
|
}
|
|
}
|
|
|
|
|
|
//filter players by the limit - taking 1 more to check if we reached the end
|
|
chain = chain.take(DEFAULT_LIMIT + 1);
|
|
const players = chain.value();
|
|
const hasReachedEnd = players.length <= DEFAULT_LIMIT;
|
|
const processedPlayers: PlayersTablePlayerType[] = players.slice(0, DEFAULT_LIMIT).map((p) => {
|
|
return {
|
|
license: p.license,
|
|
displayName: p.displayName,
|
|
playTime: p.playTime,
|
|
tsJoined: p.tsJoined,
|
|
tsLastConnection: p.tsLastConnection,
|
|
notes: p.notes ? p.notes.text : undefined,
|
|
|
|
isAdmin: p.ids.some((id) => adminsIdentifiers.includes(id)),
|
|
isOnline: onlinePlayersLicenses.has(p.license),
|
|
isWhitelisted: p.tsWhitelisted ? true : false,
|
|
// isBanned: boolean,
|
|
// warnCount: number,
|
|
// banCount: number,
|
|
};
|
|
});
|
|
|
|
txCore.metrics.txRuntime.playersTableSearchTime.count(searchTime.stop().milliseconds);
|
|
return sendTypedResp({
|
|
players: processedPlayers,
|
|
hasReachedEnd,
|
|
});
|
|
};
|