229 lines
9.0 KiB
TypeScript
229 lines
9.0 KiB
TypeScript
import { db } from '../db/index.js';
|
|
import { bankMaximum } from './bank.js';
|
|
import {
|
|
bankPageSize,
|
|
type BankAccount,
|
|
type BankOverview,
|
|
type BankPage,
|
|
type BankPending,
|
|
type BankQuery,
|
|
type BankTransfer
|
|
} from '../../bank.js';
|
|
|
|
// A GUID owns one account even when telemetry has identities in both games.
|
|
const identities = `WITH identities AS (
|
|
SELECT guid, name FROM (
|
|
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank
|
|
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
|
) WHERE rank = 1
|
|
)`;
|
|
const accounts = `${identities}, account_guids AS (
|
|
SELECT guid FROM bank_accounts UNION SELECT guid FROM bank_requests WHERE state = 'prepared'
|
|
), accounts AS (
|
|
SELECT g.guid, p.name, COALESCE(a.balance, 0) AS balance,
|
|
COALESCE(a.balance, 0) - CASE WHEN r.operation = 'withdraw' THEN r.amount ELSE 0 END AS available,
|
|
r.operation AS pendingOperation, r.amount AS pendingAmount,
|
|
l.created_at AS lastActivity, l.amount AS lastAmount
|
|
FROM account_guids g
|
|
LEFT JOIN bank_accounts a ON a.guid = g.guid
|
|
LEFT JOIN identities p ON p.guid = g.guid
|
|
LEFT JOIN bank_requests r ON r.guid = g.guid AND r.state = 'prepared'
|
|
LEFT JOIN bank_ledger l ON l.id = (SELECT id FROM bank_ledger WHERE guid = g.guid ORDER BY id DESC LIMIT 1)
|
|
)`;
|
|
// Canonical server names retain the game after match retention or server removal.
|
|
const game = (alias: string) => `COALESCE(m.game, CASE
|
|
WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5'
|
|
WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`;
|
|
const transfers = `${identities}, transfers AS (
|
|
SELECT l.id, l.guid, p.name, l.server, ${game('l')} AS game,
|
|
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
|
l.amount, l.balance, l.created_at AS createdAt
|
|
FROM bank_ledger l LEFT JOIN identities p ON p.guid = l.guid
|
|
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
|
|
)`;
|
|
const pending = `${identities}, pending AS (
|
|
SELECT r.guid, p.name, r.request_id AS requestId, r.server, ${game('r')} AS game,
|
|
r.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
|
r.operation, r.amount, r.created_at AS createdAt
|
|
FROM bank_requests r LEFT JOIN identities p ON p.guid = r.guid
|
|
LEFT JOIN leaderboard_matches m ON m.id = r.match_id WHERE r.state = 'prepared'
|
|
)`;
|
|
const emptyPage = <T>(): BankPage<T> => ({ entries: [], total: 0, offset: 0 });
|
|
|
|
export function bankOverview(query: BankQuery, now = new Date()): BankOverview {
|
|
const handle = db();
|
|
return handle.transaction(() => {
|
|
let maximum: number | null;
|
|
try {
|
|
maximum = bankMaximum();
|
|
} catch {
|
|
// Invalid deposit configuration must not hide balances or recovery information.
|
|
maximum = null;
|
|
}
|
|
const checkedAt = now.toISOString();
|
|
const summary = handle
|
|
.prepare(
|
|
`SELECT
|
|
(SELECT COALESCE(SUM(balance), 0) FROM bank_accounts) AS totalBanked,
|
|
(SELECT COUNT(*) FROM bank_accounts WHERE balance > 0) AS fundedAccounts,
|
|
(SELECT COUNT(*) FROM bank_ledger WHERE created_at >= ? AND created_at <= ?) AS transfers24h,
|
|
(SELECT COUNT(*) FROM bank_requests WHERE state = 'prepared') AS pending
|
|
`
|
|
)
|
|
.get(
|
|
new Date(now.getTime() - 86_400_000).toISOString(),
|
|
checkedAt
|
|
) as BankOverview['summary'];
|
|
const servers = (
|
|
handle
|
|
.prepare(
|
|
`SELECT server FROM bank_ledger
|
|
UNION SELECT server FROM bank_requests WHERE state = 'prepared' ORDER BY server`
|
|
)
|
|
.all() as { server: string }[]
|
|
).map((row) => row.server);
|
|
|
|
function page<T>(
|
|
cte: string,
|
|
table: string,
|
|
where: string[],
|
|
values: (string | number)[],
|
|
order: string,
|
|
requested: number
|
|
): BankPage<T> {
|
|
const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`;
|
|
const { total } = handle
|
|
.prepare(`${cte} SELECT COUNT(*) AS total ${scope}`)
|
|
.get(...values) as { total: number };
|
|
const offset = Math.min(
|
|
requested,
|
|
Math.max(0, Math.floor((total - 1) / bankPageSize) * bankPageSize)
|
|
);
|
|
const entries = handle
|
|
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
|
|
.all(...values, bankPageSize, offset) as T[];
|
|
return { entries, total, offset };
|
|
}
|
|
const where: string[] = [];
|
|
const values: (string | number)[] = [];
|
|
if (query.q) {
|
|
where.push(
|
|
"(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)"
|
|
);
|
|
values.push(query.q, query.q);
|
|
}
|
|
let accountPage = emptyPage<BankAccount>();
|
|
let transactionPage = emptyPage<BankTransfer>();
|
|
let pendingPage = emptyPage<BankPending>();
|
|
if (query.view === 'accounts') {
|
|
if (query.balance === 'funded') where.push('balance > 0');
|
|
if (query.balance === 'pending') where.push('pendingOperation IS NOT NULL');
|
|
if (query.balance === 'limit') {
|
|
where.push('balance >= ?');
|
|
values.push(maximum ?? 1_000_001);
|
|
}
|
|
accountPage = page(
|
|
accounts,
|
|
'accounts',
|
|
where,
|
|
values,
|
|
query.sort === 'recent' ? 'lastActivity DESC, guid' : 'balance DESC, guid',
|
|
query.offset
|
|
);
|
|
} else {
|
|
if (query.game) {
|
|
where.push('game = ?');
|
|
values.push(query.game);
|
|
}
|
|
if (query.server) {
|
|
where.push('server = ?');
|
|
values.push(query.server);
|
|
}
|
|
if (query.operation) {
|
|
where.push(
|
|
query.view === 'pending'
|
|
? 'operation = ?'
|
|
: query.operation === 'deposit'
|
|
? 'amount > 0'
|
|
: 'amount < 0'
|
|
);
|
|
if (query.view === 'pending') values.push(query.operation);
|
|
}
|
|
if (query.from) {
|
|
where.push('createdAt >= ?');
|
|
values.push(`${query.from}T00:00:00.000Z`);
|
|
}
|
|
if (query.to) {
|
|
where.push('createdAt <= ?');
|
|
values.push(`${query.to}T23:59:59.999Z`);
|
|
}
|
|
if (query.view === 'transactions')
|
|
transactionPage = page(
|
|
transfers,
|
|
'transfers',
|
|
where,
|
|
values,
|
|
'createdAt DESC, id DESC',
|
|
query.offset
|
|
);
|
|
else
|
|
pendingPage = page(
|
|
pending,
|
|
'pending',
|
|
where,
|
|
values,
|
|
'createdAt, server, requestId',
|
|
query.offset
|
|
);
|
|
}
|
|
let detail: BankOverview['detail'] = null;
|
|
if (query.player) {
|
|
let account = handle
|
|
.prepare(`${accounts} SELECT * FROM accounts WHERE guid = ?`)
|
|
.get(query.player) as BankAccount | undefined;
|
|
if (!account) {
|
|
const identity = handle
|
|
.prepare(`${identities} SELECT * FROM identities WHERE guid = ?`)
|
|
.get(query.player) as { guid: string; name: string } | undefined;
|
|
if (identity)
|
|
account = {
|
|
...identity,
|
|
balance: 0,
|
|
available: 0,
|
|
pendingOperation: null,
|
|
pendingAmount: null,
|
|
lastActivity: null,
|
|
lastAmount: null
|
|
};
|
|
}
|
|
if (account)
|
|
detail = {
|
|
account,
|
|
pending:
|
|
(handle
|
|
.prepare(`${pending} SELECT * FROM pending WHERE guid = ?`)
|
|
.get(query.player) as BankPending | undefined) ?? null,
|
|
history: page(
|
|
transfers,
|
|
'transfers',
|
|
['guid = ?'],
|
|
[query.player],
|
|
'createdAt DESC, id DESC',
|
|
query.historyOffset
|
|
)
|
|
};
|
|
}
|
|
return {
|
|
query,
|
|
maximum,
|
|
checkedAt,
|
|
summary,
|
|
servers,
|
|
accounts: accountPage,
|
|
transactions: transactionPage,
|
|
pending: pendingPage,
|
|
detail
|
|
};
|
|
})();
|
|
}
|