mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +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
34
apps/calendar/apps/backend/src/admin/admin.controller.ts
Normal file
34
apps/calendar/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/calendar/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/calendar/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 {}
|
||||
152
apps/calendar/apps/backend/src/admin/admin.service.ts
Normal file
152
apps/calendar/apps/backend/src/admin/admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
47
apps/chat/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/chat/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/chat/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/chat/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 {}
|
||||
148
apps/chat/apps/backend/src/admin/admin.service.ts
Normal file
148
apps/chat/apps/backend/src/admin/admin.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }),
|
||||
],
|
||||
})
|
||||
|
|
|
|||
34
apps/contacts/apps/backend/src/admin/admin.controller.ts
Normal file
34
apps/contacts/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/contacts/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/contacts/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 {}
|
||||
152
apps/contacts/apps/backend/src/admin/admin.service.ts
Normal file
152
apps/contacts/apps/backend/src/admin/admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
185
apps/manacore/apps/web/src/lib/api/services/admin.ts
Normal file
185
apps/manacore/apps/web/src/lib/api/services/admin.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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" />`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
34
apps/picture/apps/backend/src/admin/admin.controller.ts
Normal file
34
apps/picture/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/picture/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/picture/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 {}
|
||||
159
apps/picture/apps/backend/src/admin/admin.service.ts
Normal file
159
apps/picture/apps/backend/src/admin/admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
46
apps/presi/apps/backend/src/admin/admin.controller.ts
Normal file
46
apps/presi/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/presi/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/presi/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 { ServiceAuthGuard } from './guards/service-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService, ServiceAuthGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
126
apps/presi/apps/backend/src/admin/admin.service.ts
Normal file
126
apps/presi/apps/backend/src/admin/admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }),
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
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 {}
|
||||
|
|
|
|||
34
apps/zitare/apps/backend/src/admin/admin.controller.ts
Normal file
34
apps/zitare/apps/backend/src/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/zitare/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/zitare/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 {}
|
||||
87
apps/zitare/apps/backend/src/admin/admin.service.ts
Normal file
87
apps/zitare/apps/backend/src/admin/admin.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue