mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
✨ feat(admin): add user data dashboard for cross-project data visualization
Add comprehensive admin dashboard to view and manage user data across all projects: Backend: - Add admin endpoints to Chat, Todo, Contacts, Calendar, Picture, Zitare, Presi - Each backend exposes GET/DELETE /api/v1/admin/user-data/:userId - Service-to-service auth via X-Service-Key header Aggregation (mana-core-auth): - GET /api/v1/admin/users - Paginated user list with search - GET /api/v1/admin/users/:userId/data - Aggregated data from all backends - DELETE /api/v1/admin/users/:userId/data - GDPR deletion across all projects Frontend (ManaCore web): - New User Data tab in admin navigation - User search page at /admin/user-data - User detail page with ProjectDataCard components - GDPR deletion dialog with email confirmation Presi: - Migrate user_id from UUID to TEXT for Better Auth compatibility - Add SQL migration script
This commit is contained in:
parent
5b6f231e1a
commit
a2e2a5b73c
57 changed files with 3847 additions and 465 deletions
|
|
@ -25,6 +25,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"axios": "^1.7.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-auth": "^1.4.3",
|
||||
"body-parser": "^2.2.2",
|
||||
|
|
@ -65,8 +67,8 @@
|
|||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
|
|
|
|||
21
services/mana-core-auth/src/admin/admin.module.ts
Normal file
21
services/mana-core-auth/src/admin/admin.module.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { UserDataController } from './user-data.controller';
|
||||
import { UserDataService } from './user-data.service';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 5000,
|
||||
maxRedirects: 3,
|
||||
}),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [UserDataController],
|
||||
providers: [UserDataService, AdminGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
78
services/mana-core-auth/src/admin/dto/user-data.dto.ts
Normal file
78
services/mana-core-auth/src/admin/dto/user-data.dto.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
export interface EntityCount {
|
||||
entity: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectDataSummary {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
entities: EntityCount[];
|
||||
totalCount: number;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
export interface UserDataSummary {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
emailVerified: boolean;
|
||||
};
|
||||
auth: {
|
||||
sessionsCount: number;
|
||||
accountsCount: number;
|
||||
has2FA: boolean;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
credits: {
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
transactionsCount: number;
|
||||
};
|
||||
projects: ProjectDataSummary[];
|
||||
totals: {
|
||||
totalEntities: number;
|
||||
projectsWithData: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteUserDataResponse {
|
||||
success: boolean;
|
||||
deletedFromProjects: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
deletedCount?: number;
|
||||
}[];
|
||||
deletedFromAuth: {
|
||||
sessions: number;
|
||||
accounts: number;
|
||||
credits: number;
|
||||
user: boolean;
|
||||
};
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
72
services/mana-core-auth/src/admin/guards/admin.guard.ts
Normal file
72
services/mana-core-auth/src/admin/guards/admin.guard.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { BetterAuthService } from '../../auth/services/better-auth.service';
|
||||
|
||||
/**
|
||||
* Guard for admin-only endpoints
|
||||
* Checks JWT token and verifies user has admin role or is in ADMIN_USER_IDS
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AdminGuard.name);
|
||||
private readonly adminUserIds: string[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly betterAuthService: BetterAuthService
|
||||
) {
|
||||
const adminIds = this.configService.get<string>('ADMIN_USER_IDS', '');
|
||||
this.adminUserIds = adminIds ? adminIds.split(',').map((id) => id.trim()) : [];
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
this.logger.warn('Missing or invalid Authorization header');
|
||||
throw new UnauthorizedException('Missing authorization token');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
// Validate JWT using Better Auth
|
||||
const result = await this.betterAuthService.validateToken(token);
|
||||
|
||||
if (!result.valid || !result.payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const userId = result.payload.sub;
|
||||
const userRole = result.payload.role;
|
||||
|
||||
// Check if user is admin (by role or by explicit ID)
|
||||
const isAdmin = userRole === 'admin' || this.adminUserIds.includes(userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
this.logger.warn(`User ${userId} attempted admin access without permission`);
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
// Attach user info to request
|
||||
(request as any).user = result.payload;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException || error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Token validation error: ${error.message}`);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
66
services/mana-core-auth/src/admin/user-data.controller.ts
Normal file
66
services/mana-core-auth/src/admin/user-data.controller.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Logger,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { UserDataService } from './user-data.service';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
import { UserDataSummary, DeleteUserDataResponse, UserListResponse } from './dto/user-data.dto';
|
||||
|
||||
/**
|
||||
* Admin controller for cross-project user data management
|
||||
* All endpoints require admin authentication (role=admin or in ADMIN_USER_IDS)
|
||||
*/
|
||||
@Controller('api/v1/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
export class UserDataController {
|
||||
private readonly logger = new Logger(UserDataController.name);
|
||||
|
||||
constructor(private readonly userDataService: UserDataService) {}
|
||||
|
||||
/**
|
||||
* List all users with pagination and search
|
||||
* GET /api/v1/admin/users?page=1&limit=20&search=email
|
||||
*/
|
||||
@Get('users')
|
||||
async getUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string
|
||||
): Promise<UserListResponse> {
|
||||
const pageNum = parseInt(page || '1', 10);
|
||||
const limitNum = Math.min(parseInt(limit || '20', 10), 100);
|
||||
|
||||
this.logger.log(
|
||||
`Admin request: getUsers page=${pageNum} limit=${limitNum} search=${search || ''}`
|
||||
);
|
||||
return this.userDataService.getUsers(pageNum, limitNum, search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated user data from all projects
|
||||
* GET /api/v1/admin/users/:userId/data
|
||||
*/
|
||||
@Get('users/:userId/data')
|
||||
async getUserData(@Param('userId') userId: string): Promise<UserDataSummary> {
|
||||
this.logger.log(`Admin request: getUserData for userId=${userId}`);
|
||||
return this.userDataService.getUserDataSummary(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all user data across all projects (GDPR right to be forgotten)
|
||||
* DELETE /api/v1/admin/users/:userId/data
|
||||
*/
|
||||
@Delete('users/:userId/data')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteUserData(@Param('userId') userId: string): Promise<DeleteUserDataResponse> {
|
||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
||||
return this.userDataService.deleteUserData(userId);
|
||||
}
|
||||
}
|
||||
357
services/mana-core-auth/src/admin/user-data.service.ts
Normal file
357
services/mana-core-auth/src/admin/user-data.service.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { eq, sql, desc, ilike, or } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import * as schema from '../db/schema';
|
||||
import {
|
||||
UserDataSummary,
|
||||
ProjectDataSummary,
|
||||
DeleteUserDataResponse,
|
||||
UserListItem,
|
||||
UserListResponse,
|
||||
} from './dto/user-data.dto';
|
||||
|
||||
interface ProjectConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserDataService {
|
||||
private readonly logger = new Logger(UserDataService.name);
|
||||
private readonly serviceKey: string;
|
||||
private readonly projectConfigs: ProjectConfig[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService
|
||||
) {
|
||||
this.serviceKey = this.configService.get<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
||||
|
||||
// Configure backend URLs from environment or use defaults
|
||||
this.projectConfigs = this.initProjectConfigs();
|
||||
}
|
||||
|
||||
private getDatabase() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
private initProjectConfigs(): ProjectConfig[] {
|
||||
return [
|
||||
{
|
||||
id: 'chat',
|
||||
name: 'Chat',
|
||||
icon: '💬',
|
||||
url: this.configService.get('CHAT_BACKEND_URL', 'http://localhost:3002'),
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
icon: '✅',
|
||||
url: this.configService.get('TODO_BACKEND_URL', 'http://localhost:3018'),
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
icon: '👥',
|
||||
url: this.configService.get('CONTACTS_BACKEND_URL', 'http://localhost:3015'),
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar',
|
||||
icon: '📅',
|
||||
url: this.configService.get('CALENDAR_BACKEND_URL', 'http://localhost:3014'),
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
name: 'Picture',
|
||||
icon: '🎨',
|
||||
url: this.configService.get('PICTURE_BACKEND_URL', 'http://localhost:3006'),
|
||||
},
|
||||
{
|
||||
id: 'zitare',
|
||||
name: 'Zitare',
|
||||
icon: '💡',
|
||||
url: this.configService.get('ZITARE_BACKEND_URL', 'http://localhost:3007'),
|
||||
},
|
||||
{
|
||||
id: 'presi',
|
||||
name: 'Presi',
|
||||
icon: '📊',
|
||||
url: this.configService.get('PRESI_BACKEND_URL', 'http://localhost:3008'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all users with pagination
|
||||
*/
|
||||
async getUsers(page: number = 1, limit: number = 20, search?: string): Promise<UserListResponse> {
|
||||
const db = this.getDatabase();
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build base query
|
||||
const baseConditions = search
|
||||
? or(ilike(schema.users.email, `%${search}%`), ilike(schema.users.name, `%${search}%`))
|
||||
: undefined;
|
||||
|
||||
const [users, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(baseConditions)
|
||||
.orderBy(desc(schema.users.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.users)
|
||||
.where(baseConditions),
|
||||
]);
|
||||
|
||||
// Get last session for each user
|
||||
const userIds = users.map((user: typeof schema.users.$inferSelect) => user.id);
|
||||
const lastSessions =
|
||||
userIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
odriUserId: schema.sessions.userId,
|
||||
lastActivityAt: sql<Date>`MAX(${schema.sessions.lastActivityAt})`,
|
||||
})
|
||||
.from(schema.sessions)
|
||||
.where(sql`${schema.sessions.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||
.groupBy(schema.sessions.userId)
|
||||
: [];
|
||||
|
||||
const sessionMap = new Map(
|
||||
lastSessions.map((session) => [session.odriUserId, session.lastActivityAt])
|
||||
);
|
||||
|
||||
const userList: UserListItem[] = users.map((user: typeof schema.users.$inferSelect) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
lastActiveAt: sessionMap.get(user.id)?.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
users: userList,
|
||||
total: countResult[0]?.count ?? 0,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated user data from all projects
|
||||
*/
|
||||
async getUserDataSummary(userId: string): Promise<UserDataSummary> {
|
||||
const db = this.getDatabase();
|
||||
this.logger.log(`Getting user data summary for userId: ${userId}`);
|
||||
|
||||
// Get user data from local DB
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
||||
|
||||
if (!user.length) {
|
||||
throw new NotFoundException(`User ${userId} not found`);
|
||||
}
|
||||
|
||||
// Get auth data
|
||||
const [sessionsCount, accountsCount, has2FA, lastSession] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, userId)),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.userId, userId)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.twoFactorAuth)
|
||||
.where(eq(schema.twoFactorAuth.userId, userId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ lastActivityAt: schema.sessions.lastActivityAt })
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, userId))
|
||||
.orderBy(desc(schema.sessions.lastActivityAt))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
// Get credits data
|
||||
const creditsResult = await db
|
||||
.select()
|
||||
.from(schema.balances)
|
||||
.where(eq(schema.balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const transactionsCount = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.transactions)
|
||||
.where(eq(schema.transactions.userId, userId));
|
||||
|
||||
const credits = creditsResult[0];
|
||||
|
||||
// Query all backends in parallel
|
||||
const projectResults = await Promise.all(
|
||||
this.projectConfigs.map((config) => this.queryBackend(config, userId))
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
const totalEntities = projectResults.reduce(
|
||||
(sum, p) => sum + (p.available ? p.totalCount : 0),
|
||||
0
|
||||
);
|
||||
const projectsWithData = projectResults.filter((p) => p.available && p.totalCount > 0).length;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
name: user[0].name,
|
||||
role: user[0].role,
|
||||
createdAt: user[0].createdAt.toISOString(),
|
||||
emailVerified: user[0].emailVerified,
|
||||
},
|
||||
auth: {
|
||||
sessionsCount: sessionsCount[0]?.count ?? 0,
|
||||
accountsCount: accountsCount[0]?.count ?? 0,
|
||||
has2FA: has2FA.length > 0,
|
||||
lastLoginAt: lastSession[0]?.lastActivityAt?.toISOString() ?? null,
|
||||
},
|
||||
credits: {
|
||||
balance: credits?.balance ?? 0,
|
||||
totalEarned: credits?.totalEarned ?? 0,
|
||||
totalSpent: credits?.totalSpent ?? 0,
|
||||
transactionsCount: transactionsCount[0]?.count ?? 0,
|
||||
},
|
||||
projects: projectResults,
|
||||
totals: {
|
||||
totalEntities,
|
||||
projectsWithData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all user data across all projects (GDPR)
|
||||
*/
|
||||
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
|
||||
const db = this.getDatabase();
|
||||
this.logger.log(`Deleting all user data for userId: ${userId}`);
|
||||
|
||||
// Verify user exists
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
||||
|
||||
if (!user.length) {
|
||||
throw new NotFoundException(`User ${userId} not found`);
|
||||
}
|
||||
|
||||
// Delete from all backends in parallel
|
||||
const projectResults = await Promise.all(
|
||||
this.projectConfigs.map(async (config) => {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.delete(`${config.url}/api/v1/admin/user-data/${userId}`, {
|
||||
headers: { 'X-Service-Key': this.serviceKey },
|
||||
timeout: 10000,
|
||||
})
|
||||
);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
success: true,
|
||||
deletedCount: response.data?.totalDeleted ?? 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to delete data from ${config.name}: ${error.message}`);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Delete from local auth tables
|
||||
const [deletedSessions, deletedAccounts, deletedTransactions] = await Promise.all([
|
||||
db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)).returning(),
|
||||
db.delete(schema.accounts).where(eq(schema.accounts.userId, userId)).returning(),
|
||||
db.delete(schema.transactions).where(eq(schema.transactions.userId, userId)).returning(),
|
||||
]);
|
||||
|
||||
// Delete credits balance
|
||||
await db.delete(schema.balances).where(eq(schema.balances.userId, userId));
|
||||
|
||||
// Delete 2FA
|
||||
await db.delete(schema.twoFactorAuth).where(eq(schema.twoFactorAuth.userId, userId));
|
||||
|
||||
// Soft delete user (or hard delete if preferred)
|
||||
await db.update(schema.users).set({ deletedAt: new Date() }).where(eq(schema.users.id, userId));
|
||||
|
||||
const totalFromProjects = projectResults
|
||||
.filter((p) => p.success)
|
||||
.reduce((sum, p) => sum + (p.deletedCount ?? 0), 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedFromProjects: projectResults,
|
||||
deletedFromAuth: {
|
||||
sessions: deletedSessions.length,
|
||||
accounts: deletedAccounts.length,
|
||||
credits: deletedTransactions.length,
|
||||
user: true,
|
||||
},
|
||||
totalDeleted:
|
||||
totalFromProjects +
|
||||
deletedSessions.length +
|
||||
deletedAccounts.length +
|
||||
deletedTransactions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query a single backend for user data
|
||||
*/
|
||||
private async queryBackend(config: ProjectConfig, userId: string): Promise<ProjectDataSummary> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${config.url}/api/v1/admin/user-data/${userId}`, {
|
||||
headers: { 'X-Service-Key': this.serviceKey },
|
||||
timeout: 5000,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
icon: config.icon,
|
||||
available: true,
|
||||
entities: response.data.entities || [],
|
||||
totalCount: response.data.totalCount || 0,
|
||||
lastActivityAt: response.data.lastActivityAt,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Backend ${config.name} unavailable: ${error.message}`);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
icon: config.icon,
|
||||
available: false,
|
||||
error: error.code === 'ECONNREFUSED' ? 'Backend offline' : error.message,
|
||||
entities: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { AiModule } from './ai/ai.module';
|
|||
import { HealthModule } from './health/health.module';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { AnalyticsModule } from './analytics';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggerModule } from './common/logger';
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ import { LoggerModule } from './common/logger';
|
|||
ReferralsModule,
|
||||
SettingsModule,
|
||||
TagsModule,
|
||||
AdminModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue