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

@ -0,0 +1,47 @@
import {
Controller,
Get,
Delete,
Param,
UseGuards,
Logger,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { ServiceAuthGuard } from './guards/service-auth.guard';
import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto';
/**
* Admin controller for user data queries
* Used by mana-core-auth aggregation service
* Protected by X-Service-Key authentication
*/
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
/**
* Get user data counts for a specific user
* GET /api/v1/admin/user-data/:userId
*/
@Get('user-data/:userId')
async getUserData(@Param('userId') userId: string): Promise<UserDataResponse> {
this.logger.log(`Admin request: getUserData for userId=${userId}`);
return this.adminService.getUserData(userId);
}
/**
* Delete all user data (GDPR right to be forgotten)
* DELETE /api/v1/admin/user-data/:userId
*/
@Delete('user-data/:userId')
@HttpCode(HttpStatus.OK)
async deleteUserData(@Param('userId') userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
return this.adminService.deleteUserData(userId);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { DatabaseModule } from '../db/database.module';
@Module({
imports: [ConfigModule, DatabaseModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View file

@ -0,0 +1,206 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { eq, sql, desc, inArray } from 'drizzle-orm';
import * as schema from '../db/schema';
import {
UserDataResponse,
DeleteUserDataResponse,
EntityCount,
} from './dto/user-data-response.dto';
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
@Inject('DATABASE_CONNECTION')
private readonly db: NodePgDatabase<typeof schema>
) {}
/**
* Get user data counts for a specific user
*/
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count projects
const projectsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.projects)
.where(eq(schema.projects.userId, userId));
const projectsCount = projectsResult[0]?.count ?? 0;
// Count tasks
const tasksResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.tasks)
.where(eq(schema.tasks.userId, userId));
const tasksCount = tasksResult[0]?.count ?? 0;
// Count labels
const labelsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.labels)
.where(eq(schema.labels.userId, userId));
const labelsCount = labelsResult[0]?.count ?? 0;
// Count reminders
const remindersResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.reminders)
.where(eq(schema.reminders.userId, userId));
const remindersCount = remindersResult[0]?.count ?? 0;
// Count kanban boards
const kanbanBoardsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.kanbanBoards)
.where(eq(schema.kanbanBoards.userId, userId));
const kanbanBoardsCount = kanbanBoardsResult[0]?.count ?? 0;
// Get last activity (most recent task update)
const lastTask = await this.db
.select({ updatedAt: schema.tasks.updatedAt })
.from(schema.tasks)
.where(eq(schema.tasks.userId, userId))
.orderBy(desc(schema.tasks.updatedAt))
.limit(1);
const lastActivityAt = lastTask[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'projects', count: projectsCount, label: 'Projects' },
{ entity: 'tasks', count: tasksCount, label: 'Tasks' },
{ entity: 'labels', count: labelsCount, label: 'Labels' },
{ entity: 'reminders', count: remindersCount, label: 'Reminders' },
{ entity: 'kanban_boards', count: kanbanBoardsCount, label: 'Kanban Boards' },
];
const totalCount =
projectsCount + tasksCount + labelsCount + remindersCount + kanbanBoardsCount;
return {
entities,
totalCount,
lastActivityAt,
};
}
/**
* Delete all user data (GDPR right to be forgotten)
*/
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Delete reminders first (FK to tasks)
const deletedReminders = await this.db
.delete(schema.reminders)
.where(eq(schema.reminders.userId, userId))
.returning();
deletedCounts.push({
entity: 'reminders',
count: deletedReminders.length,
label: 'Reminders',
});
totalDeleted += deletedReminders.length;
// Delete task_labels (through tasks owned by user)
const userTasks = await this.db
.select({ id: schema.tasks.id })
.from(schema.tasks)
.where(eq(schema.tasks.userId, userId));
const taskIds = userTasks.map((t) => t.id);
if (taskIds.length > 0) {
const deletedTaskLabels = await this.db
.delete(schema.taskLabels)
.where(inArray(schema.taskLabels.taskId, taskIds))
.returning();
deletedCounts.push({
entity: 'task_labels',
count: deletedTaskLabels.length,
label: 'Task Labels',
});
totalDeleted += deletedTaskLabels.length;
}
// Delete labels
const deletedLabels = await this.db
.delete(schema.labels)
.where(eq(schema.labels.userId, userId))
.returning();
deletedCounts.push({
entity: 'labels',
count: deletedLabels.length,
label: 'Labels',
});
totalDeleted += deletedLabels.length;
// Delete tasks
const deletedTasks = await this.db
.delete(schema.tasks)
.where(eq(schema.tasks.userId, userId))
.returning();
deletedCounts.push({
entity: 'tasks',
count: deletedTasks.length,
label: 'Tasks',
});
totalDeleted += deletedTasks.length;
// Delete kanban columns (through boards owned by user)
const userBoards = await this.db
.select({ id: schema.kanbanBoards.id })
.from(schema.kanbanBoards)
.where(eq(schema.kanbanBoards.userId, userId));
const boardIds = userBoards.map((b) => b.id);
if (boardIds.length > 0) {
const deletedColumns = await this.db
.delete(schema.kanbanColumns)
.where(inArray(schema.kanbanColumns.boardId, boardIds))
.returning();
deletedCounts.push({
entity: 'kanban_columns',
count: deletedColumns.length,
label: 'Kanban Columns',
});
totalDeleted += deletedColumns.length;
}
// Delete kanban boards
const deletedBoards = await this.db
.delete(schema.kanbanBoards)
.where(eq(schema.kanbanBoards.userId, userId))
.returning();
deletedCounts.push({
entity: 'kanban_boards',
count: deletedBoards.length,
label: 'Kanban Boards',
});
totalDeleted += deletedBoards.length;
// Delete projects
const deletedProjects = await this.db
.delete(schema.projects)
.where(eq(schema.projects.userId, userId))
.returning();
deletedCounts.push({
entity: 'projects',
count: deletedProjects.length,
label: 'Projects',
});
totalDeleted += deletedProjects.length;
this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`);
return {
success: true,
deletedCounts,
totalDeleted,
};
}
}

View file

@ -0,0 +1,17 @@
export interface EntityCount {
entity: string;
count: number;
label: string;
}
export interface UserDataResponse {
entities: EntityCount[];
totalCount: number;
lastActivityAt?: string;
}
export interface DeleteUserDataResponse {
success: boolean;
deletedCounts: EntityCount[];
totalDeleted: number;
}

View file

@ -0,0 +1,40 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
/**
* Guard for internal service-to-service authentication using X-Service-Key header
* Used by mana-core-auth to query user data across backends
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
private readonly logger = new Logger(ServiceAuthGuard.name);
private readonly serviceKey: string;
constructor(private readonly configService: ConfigService) {
this.serviceKey = this.configService.get<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const providedKey = request.headers['x-service-key'] as string;
if (!providedKey) {
this.logger.warn('Missing X-Service-Key header');
throw new UnauthorizedException('Missing service key');
}
if (providedKey !== this.serviceKey) {
this.logger.warn('Invalid service key provided');
throw new UnauthorizedException('Invalid service key');
}
return true;
}
}

View file

@ -11,6 +11,7 @@ import { LabelModule } from './label/label.module';
import { ReminderModule } from './reminder/reminder.module';
import { KanbanModule } from './kanban/kanban.module';
import { NetworkModule } from './network/network.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -40,6 +41,7 @@ import { NetworkModule } from './network/network.module';
ReminderModule,
KanbanModule,
NetworkModule,
AdminModule,
],
})
export class AppModule {}