feat(admin): super-admin user detail dashboard

Add a read-only User Detail dashboard for super admins, reachable by
clicking a user name in the admin users list.

Backend: new `admin` module aggregating existing domain services
(no new entities). ADMIN-only endpoints under /api/v1/admin:
overview (profile, account status, wallet balance, revenue, summary
counts), wallet transactions, applications (incl. deleted/docked with
restore eligibility), build/deploy errors, tickets with conversation,
and a composite activity timeline. Adds BillingService.getRevenueSummary
and guards against a wallet get-or-create race in the overview reads.

Frontend: tabbed detail page (overview/applications/activity/errors/
tickets) with lazy per-tab queries; user names in the admin list link to
it (admin only); fa/en i18n keys and response types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-17 23:52:21 +03:30
parent 87e2224d38
commit 7958d2fa72
10 changed files with 1409 additions and 3 deletions
+29
View File
@@ -167,6 +167,35 @@ export class BillingService {
});
}
/**
* Aggregate wallet movement for a user, grouped by transaction type. Used by
* the admin user-detail dashboard. "revenue" is what the user actually spent
* on services (deductions + direct gateway payments) — i.e. platform income —
* as opposed to "charged" which is just money topped up into the wallet.
*/
async getRevenueSummary(
userId: string,
): Promise<{ revenue: number; charged: number; refunded: number }> {
const wallet = await this.getOrCreateWallet(userId);
const rows = await this.txRepo
.createQueryBuilder('tx')
.select('tx.type', 'type')
.addSelect('COALESCE(SUM(tx.amount), 0)', 'total')
.where('tx.walletId = :walletId', { walletId: wallet.id })
.groupBy('tx.type')
.getRawMany<{ type: TransactionType; total: string }>();
const totals = new Map(rows.map((r) => [r.type, Number(r.total)]));
const revenue =
(totals.get(TransactionType.DEDUCTION) ?? 0) +
(totals.get(TransactionType.GATEWAY_PAYMENT) ?? 0);
return {
revenue,
charged: totals.get(TransactionType.CHARGE) ?? 0,
refunded: totals.get(TransactionType.REFUND) ?? 0,
};
}
async recordGatewayPayment(
userId: string,
amount: number,