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

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,143 @@
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 albums
const albumsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.albums)
.where(eq(schema.albums.userId, userId));
const albumsCount = albumsResult[0]?.count ?? 0;
// Count album items (through albums)
const albumItemsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.albumItems)
.innerJoin(schema.albums, eq(schema.albumItems.albumId, schema.albums.id))
.where(eq(schema.albums.userId, userId));
const albumItemsCount = albumItemsResult[0]?.count ?? 0;
// 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 tags
const tagsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.tags)
.where(eq(schema.tags.userId, userId));
const tagsCount = tagsResult[0]?.count ?? 0;
// Count photo tags (through tags)
const photoTagsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.photoTags)
.innerJoin(schema.tags, eq(schema.photoTags.tagId, schema.tags.id))
.where(eq(schema.tags.userId, userId));
const photoTagsCount = photoTagsResult[0]?.count ?? 0;
// Get last activity (most recent album update)
const lastAlbum = await this.db
.select({ updatedAt: schema.albums.updatedAt })
.from(schema.albums)
.where(eq(schema.albums.userId, userId))
.orderBy(desc(schema.albums.updatedAt))
.limit(1);
const lastActivityAt = lastAlbum[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'albums', count: albumsCount, label: 'Alben' },
{ entity: 'album_items', count: albumItemsCount, label: 'Album-Einträge' },
{ entity: 'favorites', count: favoritesCount, label: 'Favoriten' },
{ entity: 'tags', count: tagsCount, label: 'Tags' },
{ entity: 'photo_tags', count: photoTagsCount, label: 'Foto-Tags' },
];
const totalCount = albumsCount + albumItemsCount + favoritesCount + tagsCount + photoTagsCount;
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 favorites
const deletedFavorites = await this.db
.delete(schema.favorites)
.where(eq(schema.favorites.userId, userId))
.returning();
deletedCounts.push({
entity: 'favorites',
count: deletedFavorites.length,
label: 'Favoriten',
});
totalDeleted += deletedFavorites.length;
// Delete tags (cascades to photo_tags)
const deletedTags = await this.db
.delete(schema.tags)
.where(eq(schema.tags.userId, userId))
.returning();
deletedCounts.push({
entity: 'tags',
count: deletedTags.length,
label: 'Tags',
});
totalDeleted += deletedTags.length;
// Delete albums (cascades to album_items)
const deletedAlbums = await this.db
.delete(schema.albums)
.where(eq(schema.albums.userId, userId))
.returning();
deletedCounts.push({
entity: 'albums',
count: deletedAlbums.length,
label: 'Alben',
});
totalDeleted += deletedAlbums.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

@ -6,6 +6,7 @@ import { AlbumModule } from './album/album.module';
import { FavoriteModule } from './favorite/favorite.module';
import { TagModule } from './tag/tag.module';
import { PhotoModule } from './photo/photo.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -19,6 +20,7 @@ import { PhotoModule } from './photo/photo.module';
FavoriteModule,
TagModule,
PhotoModule,
AdminModule,
],
})
export class AppModule {}

View file

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

View file

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

View file

@ -0,0 +1,154 @@
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 files
const filesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.files)
.where(eq(schema.files.userId, userId));
const filesCount = filesResult[0]?.count ?? 0;
// Count folders
const foldersResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.folders)
.where(eq(schema.folders.userId, userId));
const foldersCount = foldersResult[0]?.count ?? 0;
// Count shares
const sharesResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.shares)
.where(eq(schema.shares.userId, userId));
const sharesCount = sharesResult[0]?.count ?? 0;
// Count tags
const tagsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.tags)
.where(eq(schema.tags.userId, userId));
const tagsCount = tagsResult[0]?.count ?? 0;
// Count file tags (through files)
const fileTagsResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.fileTags)
.innerJoin(schema.files, eq(schema.fileTags.fileId, schema.files.id))
.where(eq(schema.files.userId, userId));
const fileTagsCount = fileTagsResult[0]?.count ?? 0;
// Get last activity (most recent file update)
const lastFile = await this.db
.select({ updatedAt: schema.files.updatedAt })
.from(schema.files)
.where(eq(schema.files.userId, userId))
.orderBy(desc(schema.files.updatedAt))
.limit(1);
const lastActivityAt = lastFile[0]?.updatedAt?.toISOString();
const entities: EntityCount[] = [
{ entity: 'files', count: filesCount, label: 'Dateien' },
{ entity: 'folders', count: foldersCount, label: 'Ordner' },
{ entity: 'shares', count: sharesCount, label: 'Freigaben' },
{ entity: 'tags', count: tagsCount, label: 'Tags' },
{ entity: 'file_tags', count: fileTagsCount, label: 'Datei-Tags' },
];
const totalCount = filesCount + foldersCount + sharesCount + tagsCount + fileTagsCount;
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 shares first (references files/folders)
const deletedShares = await this.db
.delete(schema.shares)
.where(eq(schema.shares.userId, userId))
.returning();
deletedCounts.push({
entity: 'shares',
count: deletedShares.length,
label: 'Freigaben',
});
totalDeleted += deletedShares.length;
// Delete tags (cascades to file_tags)
const deletedTags = await this.db
.delete(schema.tags)
.where(eq(schema.tags.userId, userId))
.returning();
deletedCounts.push({
entity: 'tags',
count: deletedTags.length,
label: 'Tags',
});
totalDeleted += deletedTags.length;
// Delete files (cascades to file_versions)
const deletedFiles = await this.db
.delete(schema.files)
.where(eq(schema.files.userId, userId))
.returning();
deletedCounts.push({
entity: 'files',
count: deletedFiles.length,
label: 'Dateien',
});
totalDeleted += deletedFiles.length;
// Delete folders
const deletedFolders = await this.db
.delete(schema.folders)
.where(eq(schema.folders.userId, userId))
.returning();
deletedCounts.push({
entity: 'folders',
count: deletedFolders.length,
label: 'Ordner',
});
totalDeleted += deletedFolders.length;
this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`);
return {
success: true,
deletedCounts,
totalDeleted,
};
}
}

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { TagModule } from './tag/tag.module';
import { TrashModule } from './trash/trash.module';
import { SearchModule } from './search/search.module';
import { StorageModule } from './storage/storage.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@ -24,6 +25,7 @@ import { StorageModule } from './storage/storage.module';
TagModule,
TrashModule,
SearchModule,
AdminModule,
],
})
export class AppModule {}