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:
Till-JS 2026-02-12 13:43:16 +01:00
parent 3de2f25552
commit 02a5172c7c
20 changed files with 830 additions and 1 deletions

View file

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

View file

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

View file

@ -0,0 +1,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,
};
}
}

View file

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

View file

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

View file

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