mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 00:01:24 +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
47
apps/todo/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/todo/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/todo/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/todo/apps/backend/src/admin/admin.module.ts
Normal 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 {}
|
||||
206
apps/todo/apps/backend/src/admin/admin.service.ts
Normal file
206
apps/todo/apps/backend/src/admin/admin.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue