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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user