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:
Till-JS 2026-02-11 14:59:18 +01:00
parent 5b6f231e1a
commit a2e2a5b73c
57 changed files with 3847 additions and 465 deletions

View file

@ -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",

View 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 {}

View 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;
}

View 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');
}
}
}

View 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);
}
}

View 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,
};
}
}
}

View file

@ -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: [
{