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,34 @@
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';
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
@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('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,152 @@
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>
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count calendars
const calendarsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.calendars)
.where(eq(schema.calendars.userId, userId));
const calendarsCount = calendarsResult[0]?.count ?? 0;
// Count events
const eventsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.events)
.where(eq(schema.events.userId, userId));
const eventsCount = eventsResult[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 calendar shares (invited by user)
const sharesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.calendarShares)
.where(eq(schema.calendarShares.invitedBy, userId));
const sharesCount = sharesResult[0]?.count ?? 0;
// Count external calendars
const externalCalendarsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.externalCalendars)
.where(eq(schema.externalCalendars.userId, userId));
const externalCalendarsCount = externalCalendarsResult[0]?.count ?? 0;
// Get last activity
const lastEvent = await this.db
.select({ updatedAt: schema.events.updatedAt })
.from(schema.events)
.where(eq(schema.events.userId, userId))
.orderBy(desc(schema.events.updatedAt))
.limit(1);
const lastActivityAt = lastEvent[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'calendars', count: calendarsCount, label: 'Calendars' },
{ entity: 'events', count: eventsCount, label: 'Events' },
{ entity: 'reminders', count: remindersCount, label: 'Reminders' },
{ entity: 'calendar_shares', count: sharesCount, label: 'Calendar Shares' },
{ entity: 'external_calendars', count: externalCalendarsCount, label: 'External Calendars' },
];
const totalCount =
calendarsCount + eventsCount + remindersCount + sharesCount + externalCalendarsCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Delete reminders
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 calendar shares (where user invited)
const deletedShares = await this.db
.delete(schema.calendarShares)
.where(eq(schema.calendarShares.invitedBy, userId))
.returning();
deletedCounts.push({
entity: 'calendar_shares',
count: deletedShares.length,
label: 'Calendar Shares',
});
totalDeleted += deletedShares.length;
// Delete events (cascades from calendars but also direct)
const deletedEvents = await this.db
.delete(schema.events)
.where(eq(schema.events.userId, userId))
.returning();
deletedCounts.push({ entity: 'events', count: deletedEvents.length, label: 'Events' });
totalDeleted += deletedEvents.length;
// Delete external calendars
const deletedExternalCalendars = await this.db
.delete(schema.externalCalendars)
.where(eq(schema.externalCalendars.userId, userId))
.returning();
deletedCounts.push({
entity: 'external_calendars',
count: deletedExternalCalendars.length,
label: 'External Calendars',
});
totalDeleted += deletedExternalCalendars.length;
// Delete calendars
const deletedCalendars = await this.db
.delete(schema.calendars)
.where(eq(schema.calendars.userId, userId))
.returning();
deletedCounts.push({ entity: 'calendars', count: deletedCalendars.length, label: 'Calendars' });
totalDeleted += deletedCalendars.length;
// Delete device tokens
const deletedDeviceTokens = await this.db
.delete(schema.deviceTokens)
.where(eq(schema.deviceTokens.userId, userId))
.returning();
deletedCounts.push({
entity: 'device_tokens',
count: deletedDeviceTokens.length,
label: 'Device Tokens',
});
totalDeleted += deletedDeviceTokens.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,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@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

@ -15,6 +15,7 @@ import { SyncModule } from './sync/sync.module';
import { NetworkModule } from './network/network.module';
import { EmailModule } from './email/email.module';
import { NotificationModule } from './notification/notification.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -48,6 +49,7 @@ import { NotificationModule } from './notification/notification.module';
ShareModule,
SyncModule,
NetworkModule,
AdminModule,
],
})
export class AppModule {}

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,148 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { eq, sql, desc } 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 conversations
const conversationsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.conversations)
.where(eq(schema.conversations.userId, userId));
const conversationsCount = conversationsResult[0]?.count ?? 0;
// Count messages (through conversations)
const messagesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.messages)
.innerJoin(schema.conversations, eq(schema.messages.conversationId, schema.conversations.id))
.where(eq(schema.conversations.userId, userId));
const messagesCount = messagesResult[0]?.count ?? 0;
// Count spaces owned by user
const spacesOwnedResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.spaces)
.where(eq(schema.spaces.ownerId, userId));
const spacesOwnedCount = spacesOwnedResult[0]?.count ?? 0;
// Count space memberships
const spaceMembershipsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, userId));
const spaceMembershipsCount = spaceMembershipsResult[0]?.count ?? 0;
// Count documents (through conversations)
const documentsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.documents)
.innerJoin(schema.conversations, eq(schema.documents.conversationId, schema.conversations.id))
.where(eq(schema.conversations.userId, userId));
const documentsCount = documentsResult[0]?.count ?? 0;
// Get last activity (most recent conversation or message)
const lastConversation = await this.db
.select({ updatedAt: schema.conversations.updatedAt })
.from(schema.conversations)
.where(eq(schema.conversations.userId, userId))
.orderBy(desc(schema.conversations.updatedAt))
.limit(1);
const lastActivityAt = lastConversation[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'conversations', count: conversationsCount, label: 'Conversations' },
{ entity: 'messages', count: messagesCount, label: 'Messages' },
{ entity: 'spaces_owned', count: spacesOwnedCount, label: 'Spaces (Owned)' },
{ entity: 'space_memberships', count: spaceMembershipsCount, label: 'Space Memberships' },
{ entity: 'documents', count: documentsCount, label: 'Documents' },
];
const totalCount =
conversationsCount +
messagesCount +
spacesOwnedCount +
spaceMembershipsCount +
documentsCount;
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 space memberships first
const deletedMemberships = await this.db
.delete(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, userId))
.returning();
deletedCounts.push({
entity: 'space_memberships',
count: deletedMemberships.length,
label: 'Space Memberships',
});
totalDeleted += deletedMemberships.length;
// Delete spaces owned by user (cascades to members)
const deletedSpaces = await this.db
.delete(schema.spaces)
.where(eq(schema.spaces.ownerId, userId))
.returning();
deletedCounts.push({
entity: 'spaces_owned',
count: deletedSpaces.length,
label: 'Spaces (Owned)',
});
totalDeleted += deletedSpaces.length;
// Delete conversations (cascades to messages and documents)
const deletedConversations = await this.db
.delete(schema.conversations)
.where(eq(schema.conversations.userId, userId))
.returning();
deletedCounts.push({
entity: 'conversations',
count: deletedConversations.length,
label: 'Conversations',
});
totalDeleted += deletedConversations.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

@ -9,6 +9,7 @@ import { TemplateModule } from './template/template.module';
import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
@Module({
@ -37,6 +38,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
SpaceModule,
DocumentModule,
ModelModule,
AdminModule,
HealthModule.forRoot({ serviceName: 'chat-backend' }),
],
})

View file

@ -0,0 +1,34 @@
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';
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
@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('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,152 @@
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>
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count contacts
const contactsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.contacts)
.where(eq(schema.contacts.userId, userId));
const contactsCount = contactsResult[0]?.count ?? 0;
// Count tags
const tagsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.contactTags)
.where(eq(schema.contactTags.userId, userId));
const tagsCount = tagsResult[0]?.count ?? 0;
// Count notes
const notesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.contactNotes)
.where(eq(schema.contactNotes.userId, userId));
const notesCount = notesResult[0]?.count ?? 0;
// Count activities
const activitiesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.contactActivities)
.where(eq(schema.contactActivities.userId, userId));
const activitiesCount = activitiesResult[0]?.count ?? 0;
// Get last activity
const lastContact = await this.db
.select({ updatedAt: schema.contacts.updatedAt })
.from(schema.contacts)
.where(eq(schema.contacts.userId, userId))
.orderBy(desc(schema.contacts.updatedAt))
.limit(1);
const lastActivityAt = lastContact[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'contacts', count: contactsCount, label: 'Contacts' },
{ entity: 'tags', count: tagsCount, label: 'Tags' },
{ entity: 'notes', count: notesCount, label: 'Notes' },
{ entity: 'activities', count: activitiesCount, label: 'Activities' },
];
const totalCount = contactsCount + tagsCount + notesCount + activitiesCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Delete activities
const deletedActivities = await this.db
.delete(schema.contactActivities)
.where(eq(schema.contactActivities.userId, userId))
.returning();
deletedCounts.push({
entity: 'activities',
count: deletedActivities.length,
label: 'Activities',
});
totalDeleted += deletedActivities.length;
// Delete notes
const deletedNotes = await this.db
.delete(schema.contactNotes)
.where(eq(schema.contactNotes.userId, userId))
.returning();
deletedCounts.push({ entity: 'notes', count: deletedNotes.length, label: 'Notes' });
totalDeleted += deletedNotes.length;
// Get contact IDs for tag deletion
const userContacts = await this.db
.select({ id: schema.contacts.id })
.from(schema.contacts)
.where(eq(schema.contacts.userId, userId));
const contactIds = userContacts.map((c) => c.id);
// Delete contact_to_tags
if (contactIds.length > 0) {
const deletedContactToTags = await this.db
.delete(schema.contactToTags)
.where(inArray(schema.contactToTags.contactId, contactIds))
.returning();
deletedCounts.push({
entity: 'contact_to_tags',
count: deletedContactToTags.length,
label: 'Contact Tags',
});
totalDeleted += deletedContactToTags.length;
}
// Delete tags
const deletedTags = await this.db
.delete(schema.contactTags)
.where(eq(schema.contactTags.userId, userId))
.returning();
deletedCounts.push({ entity: 'tags', count: deletedTags.length, label: 'Tags' });
totalDeleted += deletedTags.length;
// Delete contacts
const deletedContacts = await this.db
.delete(schema.contacts)
.where(eq(schema.contacts.userId, userId))
.returning();
deletedCounts.push({ entity: 'contacts', count: deletedContacts.length, label: 'Contacts' });
totalDeleted += deletedContacts.length;
// Delete connected accounts
const deletedConnectedAccounts = await this.db
.delete(schema.connectedAccounts)
.where(eq(schema.connectedAccounts.userId, userId))
.returning();
deletedCounts.push({
entity: 'connected_accounts',
count: deletedConnectedAccounts.length,
label: 'Connected Accounts',
});
totalDeleted += deletedConnectedAccounts.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,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@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

@ -14,6 +14,7 @@ import { DuplicatesModule } from './duplicates/duplicates.module';
import { PhotoModule } from './photo/photo.module';
import { BatchModule } from './batch/batch.module';
import { NetworkModule } from './network/network.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -38,6 +39,7 @@ import { NetworkModule } from './network/network.module';
PhotoModule,
BatchModule,
NetworkModule,
AdminModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,185 @@
/**
* Admin API Service
*
* Provides admin functionality for managing user data across all projects.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Auth API URL dynamically at runtime (admin endpoints are on auth service)
function getAuthApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
return 'http://localhost:3001/api/v1';
}
// Lazy-initialized client
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getAuthApiUrl());
}
return _client;
}
/**
* User list item from admin API
*/
export interface UserListItem {
id: string;
email: string;
name: string;
role: string;
createdAt: string;
lastActiveAt?: string;
}
/**
* User list response with pagination
*/
export interface UserListResponse {
users: UserListItem[];
total: number;
page: number;
limit: number;
}
/**
* Entity count for a project
*/
export interface EntityCount {
entity: string;
count: number;
label: string;
}
/**
* Project data summary
*/
export interface ProjectDataSummary {
projectId: string;
projectName: string;
icon: string;
available: boolean;
error?: string;
entities: EntityCount[];
totalCount: number;
lastActivityAt?: string;
}
/**
* User info
*/
export interface UserInfo {
id: string;
email: string;
name: string;
role: string;
createdAt: string;
emailVerified: boolean;
}
/**
* Auth data summary
*/
export interface AuthDataSummary {
sessionsCount: number;
accountsCount: number;
has2FA: boolean;
lastLoginAt: string | null;
}
/**
* Credits data summary
*/
export interface CreditsDataSummary {
balance: number;
totalEarned: number;
totalSpent: number;
transactionsCount: number;
}
/**
* Full user data summary
*/
export interface UserDataSummary {
user: UserInfo;
auth: AuthDataSummary;
credits: CreditsDataSummary;
projects: ProjectDataSummary[];
totals: {
totalEntities: number;
projectsWithData: number;
};
}
/**
* Delete result for a project
*/
export interface ProjectDeleteResult {
projectId: string;
projectName: string;
success: boolean;
deletedCount?: number;
error?: string;
}
/**
* Full delete response
*/
export interface DeleteUserDataResponse {
success: boolean;
deletedFromProjects: ProjectDeleteResult[];
deletedFromAuth: {
sessions: number;
accounts: number;
credits: number;
user: boolean;
};
totalDeleted: number;
}
/**
* Admin service for user data management
*/
export const adminService = {
/**
* Get list of users with pagination and search
*/
async getUsers(
page: number = 1,
limit: number = 20,
search?: string
): Promise<ApiResult<UserListResponse>> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (search) {
params.set('search', search);
}
return getClient().get<UserListResponse>(`/admin/users?${params.toString()}`);
},
/**
* Get aggregated user data from all projects
*/
async getUserData(userId: string): Promise<ApiResult<UserDataSummary>> {
return getClient().get<UserDataSummary>(`/admin/users/${userId}/data`);
},
/**
* Delete all user data (GDPR right to be forgotten)
*/
async deleteUserData(userId: string): Promise<ApiResult<DeleteUserDataResponse>> {
return getClient().delete<DeleteUserDataResponse>(`/admin/users/${userId}/data`);
},
};

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { ProjectDataSummary } from '$lib/api/services/admin';
interface Props {
project: ProjectDataSummary;
}
let { project }: Props = $props();
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min`;
if (diffHours < 24) return `vor ${diffHours} Std`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return new Date(dateStr).toLocaleDateString('de-DE');
}
</script>
<div
class="rounded-lg border bg-card shadow-sm overflow-hidden {project.available
? ''
: 'opacity-60'}"
>
<div class="p-4 border-b flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">{project.icon}</span>
<div>
<h3 class="font-semibold">{project.projectName}</h3>
{#if project.available}
<p class="text-xs text-muted-foreground">
{project.totalCount} Einträge
</p>
{:else}
<p class="text-xs text-red-500">{project.error || 'Nicht verfügbar'}</p>
{/if}
</div>
</div>
{#if project.available}
<div
class="h-2 w-2 rounded-full {project.totalCount > 0 ? 'bg-green-500' : 'bg-gray-300'}"
></div>
{:else}
<div class="h-2 w-2 rounded-full bg-red-500"></div>
{/if}
</div>
{#if project.available}
<div class="p-4">
{#if project.entities.length > 0}
<div class="space-y-2">
{#each project.entities as entity}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{entity.label}</span>
<span class="font-mono font-medium">{entity.count}</span>
</div>
{/each}
</div>
{#if project.lastActivityAt}
<div class="mt-4 pt-3 border-t">
<p class="text-xs text-muted-foreground">
Letzte Aktivitat: {formatRelativeTime(project.lastActivityAt)}
</p>
</div>
{/if}
{:else}
<p class="text-sm text-muted-foreground text-center py-4">Keine Daten vorhanden</p>
{/if}
</div>
{:else}
<div class="p-4">
<p class="text-sm text-muted-foreground text-center py-2">Backend nicht erreichbar</p>
</div>
{/if}
</div>

View file

@ -7,12 +7,14 @@
const tabs = [
{ href: '/admin', label: 'Overview', icon: 'home' },
{ href: '/admin/users', label: 'Users', icon: 'users' },
{ href: '/admin/user-data', label: 'User Data', icon: 'database' },
{ href: '/admin/system', label: 'System', icon: 'server' },
];
const icons: Record<string, string> = {
home: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />`,
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
database: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />`,
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
};

View file

@ -0,0 +1,254 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { adminService, type UserListItem } from '$lib/api/services/admin';
let users = $state<UserListItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout>;
let page = $state(1);
let total = $state(0);
const limit = 20;
let totalPages = $derived(Math.ceil(total / limit));
async function loadUsers() {
loading = true;
error = null;
const result = await adminService.getUsers(page, limit, searchQuery || undefined);
if (result.error) {
error = result.error;
users = [];
} else if (result.data) {
users = result.data.users;
total = result.data.total;
}
loading = false;
}
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
page = 1;
loadUsers();
}, 300);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min`;
if (diffHours < 24) return `vor ${diffHours} Std`;
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return formatDate(dateStr);
}
function viewUserData(userId: string) {
goto(`/admin/user-data/${userId}`);
}
onMount(() => {
loadUsers();
});
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Nutzerdaten</h1>
<p class="text-muted-foreground">Durchsuche und analysiere Nutzerdaten aller Projekte</p>
</div>
</div>
<!-- Search -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder="Nach Email oder Name suchen..."
bind:value={searchQuery}
oninput={handleSearch}
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<span class="text-sm text-muted-foreground">
{total} Nutzer gefunden
</span>
</div>
<!-- User Table -->
<div class="rounded-lg border bg-card shadow-sm overflow-hidden">
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Nutzer</h3>
</div>
{#if loading}
<div class="p-4 space-y-3">
{#each Array(5) as _}
<div class="animate-pulse flex items-center gap-4">
<div class="h-10 w-10 bg-muted rounded-full"></div>
<div class="flex-1">
<div class="h-4 bg-muted rounded w-1/4 mb-2"></div>
<div class="h-3 bg-muted rounded w-1/3"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="p-8 text-center">
<p class="text-red-500">{error}</p>
<button
onclick={() => loadUsers()}
class="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Erneut versuchen
</button>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-muted/50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Nutzer
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Rolle
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Registriert
</th>
<th
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Letzte Aktivitat
</th>
<th
class="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Aktionen
</th>
</tr>
</thead>
<tbody class="divide-y">
{#each users as user}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
>
<span class="text-sm font-medium text-primary">
{(user.name || user.email)[0].toUpperCase()}
</span>
</div>
<div>
<p class="font-medium text-sm">{user.name || '-'}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{user.role === 'admin'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
>
{user.role}
</span>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatDate(user.createdAt)}
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{formatRelativeTime(user.lastActiveAt)}
</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => viewUserData(user.id)}
class="px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-md hover:bg-primary/20 transition-colors"
>
Daten anzeigen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if users.length === 0}
<div class="p-8 text-center text-muted-foreground">Keine Nutzer gefunden</div>
{/if}
<!-- Pagination -->
{#if totalPages > 1}
<div class="p-4 border-t flex items-center justify-between">
<button
onclick={() => {
page = Math.max(1, page - 1);
loadUsers();
}}
disabled={page === 1}
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
>
Zuruck
</button>
<span class="text-sm text-muted-foreground">
Seite {page} von {totalPages}
</span>
<button
onclick={() => {
page = Math.min(totalPages, page + 1);
loadUsers();
}}
disabled={page === totalPages}
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
>
Weiter
</button>
</div>
{/if}
{/if}
</div>
</div>

View file

@ -0,0 +1,401 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import StatCard from '$lib/components/admin/StatCard.svelte';
import ProjectDataCard from '$lib/components/admin/ProjectDataCard.svelte';
import {
adminService,
type UserDataSummary,
type DeleteUserDataResponse,
} from '$lib/api/services/admin';
let userId = $derived($page.params.userId ?? '');
let userData = $state<UserDataSummary | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Delete dialog state
let showDeleteDialog = $state(false);
let deleteConfirmEmail = $state('');
let deleting = $state(false);
let deleteResult = $state<DeleteUserDataResponse | null>(null);
let deleteError = $state<string | null>(null);
async function loadUserData() {
if (!userId) {
error = 'Keine Nutzer-ID angegeben';
loading = false;
return;
}
loading = true;
error = null;
const result = await adminService.getUserData(userId);
if (result.error) {
error = result.error;
userData = null;
} else {
userData = result.data;
}
loading = false;
}
async function handleDelete() {
if (!userData || !userId || deleteConfirmEmail !== userData.user.email) {
return;
}
deleting = true;
deleteError = null;
const result = await adminService.deleteUserData(userId);
if (result.error) {
deleteError = result.error;
} else {
deleteResult = result.data;
}
deleting = false;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
onMount(() => {
loadUserData();
});
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<button
onclick={() => goto('/admin/user-data')}
class="p-2 rounded-lg hover:bg-muted transition-colors"
aria-label="Zuruck zur Nutzerliste"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex-1">
<h1 class="text-2xl font-bold">Nutzerdaten</h1>
<p class="text-muted-foreground">
{userData?.user.email || 'Laden...'}
</p>
</div>
{#if userData}
<button
onclick={() => (showDeleteDialog = true)}
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Daten loschen
</button>
{/if}
</div>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<div class="rounded-lg border bg-card p-6 shadow-sm animate-pulse">
<div class="h-4 bg-muted rounded w-20 mb-2"></div>
<div class="h-8 bg-muted rounded w-16"></div>
</div>
{/each}
</div>
{:else if error}
<div
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-6 text-center"
>
<p class="text-red-600 dark:text-red-400 mb-4">{error}</p>
<button
onclick={loadUserData}
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Erneut versuchen
</button>
</div>
{:else if userData}
<!-- User Info Card -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<span class="text-2xl font-bold text-primary">
{(userData.user.name || userData.user.email)[0].toUpperCase()}
</span>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold">{userData.user.name || 'Kein Name'}</h2>
<p class="text-muted-foreground">{userData.user.email}</p>
<div class="flex items-center gap-4 mt-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{userData.user.role === 'admin'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
>
{userData.user.role}
</span>
{#if userData.user.emailVerified}
<span class="text-xs text-green-600 flex items-center gap-1">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
Email verifiziert
</span>
{:else}
<span class="text-xs text-yellow-600 flex items-center gap-1">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
Email nicht verifiziert
</span>
{/if}
</div>
<p class="text-xs text-muted-foreground mt-2">
Registriert am {formatDate(userData.user.createdAt)}
</p>
</div>
</div>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Gesamt-Entitaten" value={userData.totals.totalEntities} icon="chart" />
<StatCard
title="Projekte mit Daten"
value="{userData.totals.projectsWithData} / {userData.projects.length}"
icon="activity"
/>
<StatCard title="Credits" value={userData.credits.balance} icon="chart" />
<StatCard title="Transaktionen" value={userData.credits.transactionsCount} icon="clock" />
</div>
<!-- Auth & Credits Details -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Auth Data -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Authentifizierung</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Aktive Sessions</span>
<span class="font-mono">{userData.auth.sessionsCount}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Verknupfte Accounts</span>
<span class="font-mono">{userData.auth.accountsCount}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">2FA aktiviert</span>
<span class={userData.auth.has2FA ? 'text-green-500' : 'text-muted-foreground'}>
{userData.auth.has2FA ? 'Ja' : 'Nein'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Letzter Login</span>
<span class="text-sm">
{userData.auth.lastLoginAt ? formatDate(userData.auth.lastLoginAt) : '-'}
</span>
</div>
</div>
</div>
<!-- Credits Data -->
<div class="rounded-lg border bg-card p-6 shadow-sm">
<h3 class="text-lg font-semibold mb-4">Credits</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Aktueller Stand</span>
<span class="font-mono font-bold text-lg">{userData.credits.balance}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Gesamt verdient</span>
<span class="font-mono text-green-600">+{userData.credits.totalEarned}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Gesamt ausgegeben</span>
<span class="font-mono text-red-500">-{userData.credits.totalSpent}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Transaktionen</span>
<span class="font-mono">{userData.credits.transactionsCount}</span>
</div>
</div>
</div>
</div>
<!-- Project Data -->
<div>
<h3 class="text-lg font-semibold mb-4">Projektdaten</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each userData.projects as project}
<ProjectDataCard {project} />
{/each}
</div>
</div>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
{#if showDeleteDialog}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-card rounded-xl shadow-xl max-w-md w-full">
{#if deleteResult}
<!-- Success State -->
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div
class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"
>
<svg
class="h-5 w-5 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-lg font-semibold">Loschung abgeschlossen</h3>
</div>
<p class="text-sm text-muted-foreground mb-4">
Insgesamt wurden <strong>{deleteResult.totalDeleted}</strong> Eintrage geloscht.
</p>
<div class="space-y-2 mb-6">
{#each deleteResult.deletedFromProjects as project}
<div class="flex items-center justify-between text-sm">
<span>{project.projectName}</span>
{#if project.success}
<span class="text-green-600">{project.deletedCount} geloscht</span>
{:else}
<span class="text-red-500">Fehler</span>
{/if}
</div>
{/each}
<div class="pt-2 border-t">
<div class="flex items-center justify-between text-sm">
<span>Sessions</span>
<span>{deleteResult.deletedFromAuth.sessions}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span>Accounts</span>
<span>{deleteResult.deletedFromAuth.accounts}</span>
</div>
</div>
</div>
<button
onclick={() => goto('/admin/user-data')}
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Zuruck zur Ubersicht
</button>
</div>
{:else}
<!-- Confirmation State -->
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div
class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"
>
<svg
class="h-5 w-5 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold text-red-600">Daten unwiderruflich loschen?</h3>
</div>
<p class="text-sm text-muted-foreground mb-4">
Diese Aktion loscht <strong>alle Daten</strong> des Nutzers aus allen Projekten. Dies umfasst:
</p>
<ul class="text-sm text-muted-foreground mb-4 list-disc list-inside space-y-1">
<li>Alle Projektdaten (Chat, Todo, Calendar, etc.)</li>
<li>Alle Sessions und verknupften Accounts</li>
<li>Credits und Transaktionshistorie</li>
<li>Das Nutzerkonto selbst</li>
</ul>
<div class="mb-4">
<label for="delete-confirm-email" class="block text-sm font-medium mb-2">
Zur Bestätigung, geben Sie die Email-Adresse ein:
</label>
<input
id="delete-confirm-email"
type="email"
placeholder={userData?.user.email}
bind:value={deleteConfirmEmail}
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-red-500/50"
/>
</div>
{#if deleteError}
<p class="text-sm text-red-500 mb-4">{deleteError}</p>
{/if}
<div class="flex gap-3">
<button
onclick={() => {
showDeleteDialog = false;
deleteConfirmEmail = '';
deleteError = null;
}}
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
disabled={deleting}
>
Abbrechen
</button>
<button
onclick={handleDelete}
disabled={deleting || deleteConfirmEmail !== userData?.user.email}
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if deleting}
Losche...
{:else}
Endgültig loschen
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,34 @@
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';
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
@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('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,159 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { eq, sql, desc } 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>
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count images
const imagesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.images)
.where(eq(schema.images.userId, userId));
const imagesCount = imagesResult[0]?.count ?? 0;
// Count image generations
const generationsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.imageGenerations)
.where(eq(schema.imageGenerations.userId, userId));
const generationsCount = generationsResult[0]?.count ?? 0;
// Count boards
const boardsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.boards)
.where(eq(schema.boards.userId, userId));
const boardsCount = boardsResult[0]?.count ?? 0;
// Count image likes
const likesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.imageLikes)
.where(eq(schema.imageLikes.userId, userId));
const likesCount = likesResult[0]?.count ?? 0;
// Get last activity
const lastImage = await this.db
.select({ updatedAt: schema.images.updatedAt })
.from(schema.images)
.where(eq(schema.images.userId, userId))
.orderBy(desc(schema.images.updatedAt))
.limit(1);
const lastActivityAt = lastImage[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'images', count: imagesCount, label: 'Images' },
{ entity: 'image_generations', count: generationsCount, label: 'Image Generations' },
{ entity: 'boards', count: boardsCount, label: 'Boards' },
{ entity: 'image_likes', count: likesCount, label: 'Image Likes' },
];
const totalCount = imagesCount + generationsCount + boardsCount + likesCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Delete image likes
const deletedLikes = await this.db
.delete(schema.imageLikes)
.where(eq(schema.imageLikes.userId, userId))
.returning();
deletedCounts.push({ entity: 'image_likes', count: deletedLikes.length, label: 'Image Likes' });
totalDeleted += deletedLikes.length;
// Delete board items (through boards)
const userBoards = await this.db
.select({ id: schema.boards.id })
.from(schema.boards)
.where(eq(schema.boards.userId, userId));
if (userBoards.length > 0) {
const boardIds = userBoards.map((b) => b.id);
const deletedBoardItems = await this.db
.delete(schema.boardItems)
.where(sql`${schema.boardItems.boardId} IN (${sql.join(boardIds, sql`, `)})`)
.returning();
deletedCounts.push({
entity: 'board_items',
count: deletedBoardItems.length,
label: 'Board Items',
});
totalDeleted += deletedBoardItems.length;
}
// Delete boards
const deletedBoards = await this.db
.delete(schema.boards)
.where(eq(schema.boards.userId, userId))
.returning();
deletedCounts.push({ entity: 'boards', count: deletedBoards.length, label: 'Boards' });
totalDeleted += deletedBoards.length;
// Delete batch generations
const deletedBatchGenerations = await this.db
.delete(schema.batchGenerations)
.where(eq(schema.batchGenerations.userId, userId))
.returning();
deletedCounts.push({
entity: 'batch_generations',
count: deletedBatchGenerations.length,
label: 'Batch Generations',
});
totalDeleted += deletedBatchGenerations.length;
// Delete image generations
const deletedGenerations = await this.db
.delete(schema.imageGenerations)
.where(eq(schema.imageGenerations.userId, userId))
.returning();
deletedCounts.push({
entity: 'image_generations',
count: deletedGenerations.length,
label: 'Image Generations',
});
totalDeleted += deletedGenerations.length;
// Delete images
const deletedImages = await this.db
.delete(schema.images)
.where(eq(schema.images.userId, userId))
.returning();
deletedCounts.push({ entity: 'images', count: deletedImages.length, label: 'Images' });
totalDeleted += deletedImages.length;
// Delete profiles (profile ID is the user ID)
const deletedProfiles = await this.db
.delete(schema.profiles)
.where(eq(schema.profiles.id, userId))
.returning();
deletedCounts.push({ entity: 'profiles', count: deletedProfiles.length, label: 'Profiles' });
totalDeleted += deletedProfiles.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,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@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

@ -13,6 +13,7 @@ import { GenerateModule } from './generate/generate.module';
import { ExploreModule } from './explore/explore.module';
import { ProfileModule } from './profile/profile.module';
import { BatchModule } from './batch/batch.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -42,6 +43,7 @@ import { BatchModule } from './batch/batch.module';
ExploreModule,
ProfileModule,
BatchModule,
AdminModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,46 @@
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 cross-service user data management
* All endpoints require service key authentication (X-Service-Key header)
*/
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
/**
* Get user data summary for this backend
* Called by mana-core-auth admin service to aggregate cross-project data
*/
@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 from this backend (GDPR right to be forgotten)
* Called by mana-core-auth admin service during cross-project deletion
*/
@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 { ServiceAuthGuard } from './guards/service-auth.guard';
@Module({
imports: [ConfigModule],
controllers: [AdminController],
providers: [AdminService, ServiceAuthGuard],
})
export class AdminModule {}

View file

@ -0,0 +1,126 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { eq, sql, desc, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
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: Database
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count decks
const decksResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
const decksCount = decksResult[0]?.count ?? 0;
// Count slides (through decks)
const userDecks = await this.db
.select({ id: schema.decks.id })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
let slidesCount = 0;
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
const slidesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.slides)
.where(inArray(schema.slides.deckId, deckIds));
slidesCount = slidesResult[0]?.count ?? 0;
}
// Count shared decks (through decks)
let sharedDecksCount = 0;
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
const sharedResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.sharedDecks)
.where(inArray(schema.sharedDecks.deckId, deckIds));
sharedDecksCount = sharedResult[0]?.count ?? 0;
}
// Get last activity
const lastDeck = await this.db
.select({ updatedAt: schema.decks.updatedAt })
.from(schema.decks)
.where(eq(schema.decks.userId, userId))
.orderBy(desc(schema.decks.updatedAt))
.limit(1);
const lastActivityAt = lastDeck[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'decks', count: decksCount, label: 'Decks' },
{ entity: 'slides', count: slidesCount, label: 'Slides' },
{ entity: 'shared_decks', count: sharedDecksCount, label: 'Shared Links' },
];
const totalCount = decksCount + slidesCount + sharedDecksCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Get user's decks first
const userDecks = await this.db
.select({ id: schema.decks.id })
.from(schema.decks)
.where(eq(schema.decks.userId, userId));
if (userDecks.length > 0) {
const deckIds = userDecks.map((d) => d.id);
// Delete shared decks
const deletedShared = await this.db
.delete(schema.sharedDecks)
.where(inArray(schema.sharedDecks.deckId, deckIds))
.returning();
deletedCounts.push({
entity: 'shared_decks',
count: deletedShared.length,
label: 'Shared Links',
});
totalDeleted += deletedShared.length;
// Delete slides (cascade should handle this, but let's be explicit)
const deletedSlides = await this.db
.delete(schema.slides)
.where(inArray(schema.slides.deckId, deckIds))
.returning();
deletedCounts.push({ entity: 'slides', count: deletedSlides.length, label: 'Slides' });
totalDeleted += deletedSlides.length;
}
// Delete decks
const deletedDecks = await this.db
.delete(schema.decks)
.where(eq(schema.decks.userId, userId))
.returning();
deletedCounts.push({ entity: 'decks', count: deletedDecks.length, label: 'Decks' });
totalDeleted += deletedDecks.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

@ -5,6 +5,7 @@ import { DeckModule } from './deck/deck.module';
import { SlideModule } from './slide/slide.module';
import { ThemeModule } from './theme/theme.module';
import { ShareModule } from './share/share.module';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
@Module({
@ -18,6 +19,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
SlideModule,
ThemeModule,
ShareModule,
AdminModule,
HealthModule.forRoot({ serviceName: 'presi-backend' }),
],
})

View file

@ -0,0 +1,20 @@
-- Migration: Change user_id from UUID to TEXT
-- This allows compatibility with Better Auth nanoid-based user IDs
-- Step 1: Add a temporary column with the new type
ALTER TABLE decks ADD COLUMN user_id_new TEXT;
-- Step 2: Copy and convert existing data (UUID to TEXT)
UPDATE decks SET user_id_new = user_id::text WHERE user_id IS NOT NULL;
-- Step 3: Drop the old column
ALTER TABLE decks DROP COLUMN user_id;
-- Step 4: Rename the new column
ALTER TABLE decks RENAME COLUMN user_id_new TO user_id;
-- Step 5: Add NOT NULL constraint
ALTER TABLE decks ALTER COLUMN user_id SET NOT NULL;
-- Step 6: Add index for performance
CREATE INDEX IF NOT EXISTS decks_user_id_idx ON decks(user_id);

View file

@ -6,7 +6,7 @@ import { sharedDecks } from './shared-decks.schema';
export const decks = pgTable('decks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
userId: text('user_id').notNull(), // TEXT for Better Auth nanoid user IDs
title: text('title').notNull(),
description: text('description'),
themeId: uuid('theme_id').references(() => themes.id),

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

View file

@ -0,0 +1,34 @@
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';
@Controller('api/v1/admin')
@UseGuards(ServiceAuthGuard)
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(private readonly adminService: AdminService) {}
@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('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,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { eq, sql, desc } 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>
) {}
async getUserData(userId: string): Promise<UserDataResponse> {
this.logger.log(`Getting user data for userId: ${userId}`);
// Count favorites
const favoritesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.favorites)
.where(eq(schema.favorites.userId, userId));
const favoritesCount = favoritesResult[0]?.count ?? 0;
// Count user lists
const userListsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.userLists)
.where(eq(schema.userLists.userId, userId));
const userListsCount = userListsResult[0]?.count ?? 0;
// Get last activity
const lastFavorite = await this.db
.select({ createdAt: schema.favorites.createdAt })
.from(schema.favorites)
.where(eq(schema.favorites.userId, userId))
.orderBy(desc(schema.favorites.createdAt))
.limit(1);
const lastActivityAt = lastFavorite[0]?.createdAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'favorites', count: favoritesCount, label: 'Favorites' },
{ entity: 'user_lists', count: userListsCount, label: 'User Lists' },
];
const totalCount = favoritesCount + userListsCount;
return { entities, totalCount, lastActivityAt };
}
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
this.logger.log(`Deleting user data for userId: ${userId}`);
const deletedCounts: EntityCount[] = [];
let totalDeleted = 0;
// Delete favorites
const deletedFavorites = await this.db
.delete(schema.favorites)
.where(eq(schema.favorites.userId, userId))
.returning();
deletedCounts.push({ entity: 'favorites', count: deletedFavorites.length, label: 'Favorites' });
totalDeleted += deletedFavorites.length;
// Delete user lists
const deletedUserLists = await this.db
.delete(schema.userLists)
.where(eq(schema.userLists.userId, userId))
.returning();
deletedCounts.push({
entity: 'user_lists',
count: deletedUserLists.length,
label: 'User Lists',
});
totalDeleted += deletedUserLists.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,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@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

@ -4,6 +4,7 @@ import { DatabaseModule } from './db/database.module';
import { FavoriteModule } from './favorite/favorite.module';
import { ListModule } from './list/list.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -15,6 +16,7 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
FavoriteModule,
ListModule,
HealthModule.forRoot({ serviceName: 'quote-backend' }),
AdminModule,
],
})
export class AppModule {}