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
@@ -0,0 +1,71 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AdminUsersService } from './admin-users.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
/**
* Super-admin "User Detail" dashboard API. Read-only aggregation of everything
* about a single user. ADMIN only — wallet/revenue data is too sensitive for
* technical/sales staff.
*/
@ApiTags('Admin')
@ApiBearerAuth()
@Controller('admin')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminUsersController {
constructor(private readonly adminUsers: AdminUsersService) {}
@Get('users/:id')
@ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' })
getOverview(@Param('id') id: string) {
return this.adminUsers.getOverview(id);
}
@Get('users/:id/wallet/transactions')
@ApiOperation({ summary: 'Wallet transaction history' })
@ApiQuery({ name: 'limit', required: false })
getTransactions(@Param('id') id: string, @Query('limit') limit?: string) {
const n = limit ? Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200) : 50;
return this.adminUsers.getTransactions(id, n);
}
@Get('users/:id/applications')
@ApiOperation({ summary: 'All applications (incl. deleted/docked) with restore status' })
getApplications(@Param('id') id: string) {
return this.adminUsers.getApplications(id);
}
@Get('users/:id/errors')
@ApiOperation({ summary: 'Build/deploy failures across the user applications' })
getErrors(@Param('id') id: string) {
return this.adminUsers.getErrors(id);
}
@Get('users/:id/tickets')
@ApiOperation({ summary: 'Support tickets with conversation' })
getTickets(@Param('id') id: string) {
return this.adminUsers.getTickets(id);
}
@Get('users/:id/activity')
@ApiOperation({ summary: 'Composite activity timeline' })
getActivity(@Param('id') id: string) {
return this.adminUsers.getActivity(id);
}
@Get('deployments/:deploymentId/logs')
@ApiOperation({ summary: 'Build/deploy logs for a single deployment' })
getDeploymentLogs(@Param('deploymentId') deploymentId: string) {
return this.adminUsers.getDeploymentLogs(deploymentId);
}
}
+333
View File
@@ -0,0 +1,333 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { BillingService } from '../billing/billing.service';
import { ApplicationsService } from '../applications/applications.service';
import { TicketsService } from '../tickets/tickets.service';
import { DeploymentsService } from '../deployments/deployments.service';
import {
AppLifecycleStatus,
DeploymentStatus,
ProductType,
TicketStatus,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
import { Ticket } from '../tickets/entities/ticket.entity';
/** How a non-active application can be brought back, for the admin UI. */
type RestoreEligibility = 'restorable' | 'recoverable' | 'none';
/**
* Read-only aggregation for the super-admin "User Detail" dashboard. Pulls
* everything about a single user from the existing domain services so the panel
* can show profile, wallet, apps, errors, tickets and an activity timeline —
* without duplicating per-module endpoints.
*/
@Injectable()
export class AdminUsersService {
constructor(
private readonly usersService: UsersService,
private readonly billingService: BillingService,
private readonly applicationsService: ApplicationsService,
private readonly ticketsService: TicketsService,
private readonly deploymentsService: DeploymentsService,
) {}
/** Overview tab: profile + account status + wallet + revenue + summary counts. */
async getOverview(userId: string) {
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
const { password, ...profile } = user;
// Ensure the wallet row exists before the parallel reads below — otherwise
// getBalance and getRevenueSummary both lazily create it at once and race on
// the unique (userId) constraint.
await this.billingService.getOrCreateWallet(userId);
const [balance, revenue, apps, tickets, recentTx] = await Promise.all([
this.billingService.getBalance(userId),
this.billingService.getRevenueSummary(userId),
this.applicationsService.findAllByUser(userId),
this.ticketsService.findMyTickets(userId),
this.billingService.getTransactions(userId, 10),
]);
return {
profile,
wallet: { balance: balance.balance },
revenue,
counts: this.computeCounts(apps, tickets),
recentTransactions: recentTx.map((t) => this.txPublic(t)),
};
}
/** Wallet transaction history. */
async getTransactions(userId: string, limit = 50) {
await this.assertUserExists(userId);
const txs = await this.billingService.getTransactions(userId, limit);
return txs.map((t) => this.txPublic(t));
}
/** All applications including deleted/docked ones, with restore eligibility. */
async getApplications(userId: string) {
await this.assertUserExists(userId);
const apps = await this.applicationsService.findAllByUser(userId);
return apps.map((a) => this.appPublic(a));
}
/** Build/deploy failures across all of the user's applications. */
async getErrors(userId: string) {
await this.assertUserExists(userId);
const apps = await this.applicationsService.findAllByUser(userId);
const failures: Array<Record<string, unknown>> = [];
for (const app of apps) {
const deps = [...(app.deployments ?? [])].sort(
(a, b) => this.time(b.createdAt) - this.time(a.createdAt),
);
for (const d of deps) {
if (
d.status !== DeploymentStatus.BUILD_FAILED &&
d.status !== DeploymentStatus.FAILED
) {
continue;
}
// Resolved once a later successful (running) deployment exists for the app.
const resolved = deps.some(
(o) =>
o.status === DeploymentStatus.RUNNING &&
this.time(o.createdAt) > this.time(d.createdAt),
);
failures.push({
deploymentId: d.id,
applicationId: app.id,
applicationName: app.name,
status: d.status,
errorMessage: d.errorMessage ?? null,
createdAt: d.createdAt,
finishedAt: d.finishedAt ?? null,
resolved,
});
}
}
return failures.sort(
(a, b) => this.time(b.createdAt as Date) - this.time(a.createdAt as Date),
);
}
/** Build/deploy logs for a single deployment (admin can read any user's). */
async getDeploymentLogs(deploymentId: string) {
const d = await this.deploymentsService.findOne(deploymentId);
return {
id: d.id,
status: d.status,
errorMessage: d.errorMessage ?? null,
buildLog: d.buildLog ?? null,
deployLog: d.deployLog ?? null,
createdAt: d.createdAt,
finishedAt: d.finishedAt ?? null,
};
}
/** Support tickets with their full conversation (sensitive sender data stripped). */
async getTickets(userId: string) {
await this.assertUserExists(userId);
const tickets = await this.ticketsService.findMyTickets(userId);
return tickets.map((t) => ({
id: t.id,
subject: t.subject,
department: t.department,
status: t.status,
priority: t.priority,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
closedAt: t.closedAt ?? null,
messageCount: t.messages?.length ?? 0,
messages: [...(t.messages ?? [])]
.sort((a, b) => this.time(a.createdAt) - this.time(b.createdAt))
.map((m) => ({
id: m.id,
message: m.message,
senderRole: m.senderRole,
senderName: m.sender
? `${m.sender.firstName ?? ''} ${m.sender.lastName ?? ''}`.trim()
: null,
createdAt: m.createdAt,
})),
}));
}
/** Composite activity timeline built from existing data (works retroactively). */
async getActivity(userId: string) {
await this.assertUserExists(userId);
const [apps, txs, tickets] = await Promise.all([
this.applicationsService.findAllByUser(userId),
this.billingService.getTransactions(userId, 100),
this.ticketsService.findMyTickets(userId),
]);
const events: Array<{
type: string;
title: string;
at: Date;
meta: Record<string, unknown>;
}> = [];
for (const a of apps) {
events.push({
type: 'app_created',
title: a.name,
at: a.createdAt,
meta: { productType: a.productType },
});
if (a.dockedAt) {
events.push({ type: 'app_docked', title: a.name, at: a.dockedAt, meta: {} });
}
if (a.lifecycleStatus === AppLifecycleStatus.DELETED && a.scheduledDeletionAt) {
events.push({
type: 'app_deleted',
title: a.name,
at: a.scheduledDeletionAt,
meta: {},
});
}
for (const d of a.deployments ?? []) {
events.push({
type: 'deployment',
title: a.name,
at: d.createdAt,
meta: { status: d.status },
});
}
}
for (const t of txs) {
events.push({
type: 'transaction',
title: t.description || t.type,
at: t.createdAt,
meta: { txType: t.type, amount: Number(t.amount) },
});
}
for (const t of tickets) {
events.push({
type: 'ticket',
title: t.subject,
at: t.createdAt,
meta: { status: t.status, department: t.department },
});
}
return events
.sort((a, b) => this.time(b.at) - this.time(a.at))
.slice(0, 100);
}
// ── helpers ──────────────────────────────────────────
private async assertUserExists(userId: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
}
private computeCounts(apps: Application[], tickets: Ticket[]) {
const isManaged = (a: Application) =>
!!a.productType && a.productType !== ProductType.APPLICATION;
return {
appsTotal: apps.length,
appsActive: apps.filter((a) => a.lifecycleStatus === AppLifecycleStatus.ACTIVE)
.length,
appsDeleted: apps.filter(
(a) =>
a.lifecycleStatus === AppLifecycleStatus.DELETED ||
a.lifecycleStatus === AppLifecycleStatus.DOCKED,
).length,
managedServices: apps.filter(isManaged).length,
optionalServices: apps.reduce(
(n, a) =>
n +
(a.enableRedis ? 1 : 0) +
(a.enableRabbitmq ? 1 : 0) +
(a.enableElasticsearch ? 1 : 0),
0,
),
deployments: apps.reduce((n, a) => n + (a.deployments?.length ?? 0), 0),
ticketsTotal: tickets.length,
ticketsOpen: tickets.filter((t) => t.status !== TicketStatus.CLOSED).length,
};
}
private appPublic(a: Application) {
// findAllByUser orders deployments newest-first, so [0] is the latest.
const latest = a.deployments?.[0];
return {
id: a.id,
name: a.name,
productType: a.productType,
runtime: a.runtime,
databaseType: a.databaseType,
lifecycleStatus: a.lifecycleStatus,
billingCycle: a.billingCycle ?? null,
subdomain: a.subdomain ?? null,
customDomain: a.customDomain ?? null,
enableRedis: a.enableRedis,
enableRabbitmq: a.enableRabbitmq,
enableElasticsearch: a.enableElasticsearch,
cpuLimit: a.cpuLimit,
memoryLimit: a.memoryLimit,
replicas: a.replicas,
createdAt: a.createdAt,
planExpiresAt: a.planExpiresAt ?? null,
scheduledDeletionAt: a.scheduledDeletionAt ?? null,
dockedAt: a.dockedAt ?? null,
restorable: this.restoreEligibility(a),
latestDeployment: latest
? { id: latest.id, status: latest.status, createdAt: latest.createdAt }
: null,
};
}
private restoreEligibility(a: Application): RestoreEligibility {
if (a.lifecycleStatus === AppLifecycleStatus.DOCKED && a.dockSnapshotId) {
return 'restorable';
}
if (
a.lifecycleStatus === AppLifecycleStatus.SUSPENDED ||
a.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION
) {
return 'recoverable';
}
return 'none';
}
private txPublic(t: {
id: string;
type: unknown;
amount: number;
balanceAfter: number;
description?: string | null;
applicationId?: string | null;
invoiceId?: string | null;
createdAt: Date;
}) {
return {
id: t.id,
type: t.type,
amount: Number(t.amount),
balanceAfter: Number(t.balanceAfter),
description: t.description ?? null,
applicationId: t.applicationId ?? null,
invoiceId: t.invoiceId ?? null,
createdAt: t.createdAt,
};
}
private time(d: Date | string): number {
return new Date(d).getTime();
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { UsersModule } from '../users/users.module';
import { BillingModule } from '../billing/billing.module';
import { ApplicationsModule } from '../applications/applications.module';
import { DeploymentsModule } from '../deployments/deployments.module';
import { TicketsModule } from '../tickets/tickets.module';
/**
* Super-admin aggregation module. Imports the domain modules (which export
* their services) so the admin controller can read everything about a user
* without re-implementing per-module logic.
*/
@Module({
imports: [
UsersModule,
BillingModule,
ApplicationsModule,
DeploymentsModule,
TicketsModule,
],
controllers: [AdminUsersController],
providers: [AdminUsersService],
})
export class AdminModule {}
+2
View File
@@ -14,6 +14,7 @@ import { BillingModule } from './billing/billing.module';
import { SnapshotsModule } from './snapshots/snapshots.module';
import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import { AdminModule } from './admin/admin.module';
import configuration from './config/configuration';
@Module({
@@ -66,6 +67,7 @@ import configuration from './config/configuration';
SnapshotsModule,
LifecycleModule,
ApplicationMigrationsModule,
AdminModule,
],
})
export class AppModule {}
+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,