mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 19:46:42 +02:00
feat(admin): add GDPR user-data endpoints to photos, clock, storage backends
- Add admin modules with GET/DELETE /api/v1/admin/user-data/:userId - Photos: albums, favorites, tags counting and deletion - Clock: alarms, timers, world clocks, presets counting and deletion - Storage: files, folders, shares, tags counting and deletion - Update UserDataService to include photos, clock, storage backends - Add ADMIN_SERVICE_KEY env var to all backends in docker-compose - Build storage-backend locally instead of using GHCR image Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3de2f25552
commit
02a5172c7c
20 changed files with 830 additions and 1 deletions
47
apps/clock/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/clock/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/clock/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/clock/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 {}
|
||||
145
apps/clock/apps/backend/src/admin/admin.service.ts
Normal file
145
apps/clock/apps/backend/src/admin/admin.service.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
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: PostgresJsDatabase<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 alarms
|
||||
const alarmsResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.alarms)
|
||||
.where(eq(schema.alarms.userId, userId));
|
||||
const alarmsCount = alarmsResult[0]?.count ?? 0;
|
||||
|
||||
// Count timers
|
||||
const timersResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.timers)
|
||||
.where(eq(schema.timers.userId, userId));
|
||||
const timersCount = timersResult[0]?.count ?? 0;
|
||||
|
||||
// Count world clocks
|
||||
const worldClocksResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.worldClocks)
|
||||
.where(eq(schema.worldClocks.userId, userId));
|
||||
const worldClocksCount = worldClocksResult[0]?.count ?? 0;
|
||||
|
||||
// Count presets
|
||||
const presetsResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.presets)
|
||||
.where(eq(schema.presets.userId, userId));
|
||||
const presetsCount = presetsResult[0]?.count ?? 0;
|
||||
|
||||
// Get last activity (most recent alarm update)
|
||||
const lastAlarm = await this.db
|
||||
.select({ updatedAt: schema.alarms.updatedAt })
|
||||
.from(schema.alarms)
|
||||
.where(eq(schema.alarms.userId, userId))
|
||||
.orderBy(desc(schema.alarms.updatedAt))
|
||||
.limit(1);
|
||||
const lastActivityAt = lastAlarm[0]?.updatedAt?.toISOString();
|
||||
|
||||
const entities: EntityCount[] = [
|
||||
{ entity: 'alarms', count: alarmsCount, label: 'Wecker' },
|
||||
{ entity: 'timers', count: timersCount, label: 'Timer' },
|
||||
{ entity: 'world_clocks', count: worldClocksCount, label: 'Weltuhren' },
|
||||
{ entity: 'presets', count: presetsCount, label: 'Vorlagen' },
|
||||
];
|
||||
|
||||
const totalCount = alarmsCount + timersCount + worldClocksCount + presetsCount;
|
||||
|
||||
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 alarms
|
||||
const deletedAlarms = await this.db
|
||||
.delete(schema.alarms)
|
||||
.where(eq(schema.alarms.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'alarms',
|
||||
count: deletedAlarms.length,
|
||||
label: 'Wecker',
|
||||
});
|
||||
totalDeleted += deletedAlarms.length;
|
||||
|
||||
// Delete timers
|
||||
const deletedTimers = await this.db
|
||||
.delete(schema.timers)
|
||||
.where(eq(schema.timers.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'timers',
|
||||
count: deletedTimers.length,
|
||||
label: 'Timer',
|
||||
});
|
||||
totalDeleted += deletedTimers.length;
|
||||
|
||||
// Delete world clocks
|
||||
const deletedWorldClocks = await this.db
|
||||
.delete(schema.worldClocks)
|
||||
.where(eq(schema.worldClocks.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'world_clocks',
|
||||
count: deletedWorldClocks.length,
|
||||
label: 'Weltuhren',
|
||||
});
|
||||
totalDeleted += deletedWorldClocks.length;
|
||||
|
||||
// Delete presets
|
||||
const deletedPresets = await this.db
|
||||
.delete(schema.presets)
|
||||
.where(eq(schema.presets.userId, userId))
|
||||
.returning();
|
||||
deletedCounts.push({
|
||||
entity: 'presets',
|
||||
count: deletedPresets.length,
|
||||
label: 'Vorlagen',
|
||||
});
|
||||
totalDeleted += deletedPresets.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { AlarmModule } from './alarm/alarm.module';
|
|||
import { TimerModule } from './timer/timer.module';
|
||||
import { WorldClockModule } from './world-clock/world-clock.module';
|
||||
import { PresetModule } from './preset/preset.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -26,6 +27,7 @@ import { PresetModule } from './preset/preset.module';
|
|||
TimerModule,
|
||||
WorldClockModule,
|
||||
PresetModule,
|
||||
AdminModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue