diff --git a/backend/api-modules.md b/backend/api-modules.md index 5666704d4..4da4cf5b5 100644 --- a/backend/api-modules.md +++ b/backend/api-modules.md @@ -1,17 +1,20 @@ -| Module | Path | Description | -| :---------------------------- | :----------------------- | :------------------------- | -| [`access`](access.md) | `/access` | Docs for access module | -| [`actions`](actions.md) | `/actions` | Docs for actions module | -| [`health`](health.md) | `/api/health` | Docs for health module | -| [`auth`](auth.md) | `/auth` | Docs for auth module | -| [`category`](category.md) | `/category` | Docs for category module | -| [`file`](file.md) | `/files` | Docs for file module | -| [`foxglove`](foxglove.md) | `/integrations/foxglove` | Docs for foxglove module | -| [`mission`](mission.md) | `/mission` | Docs for mission module | -| [`oldproject`](oldproject.md) | `/oldProject` | Docs for oldproject module | -| [`project`](project.md) | `/projects` | Docs for project module | -| [`tag`](tag.md) | `/tag` | Docs for tag module | -| [`templates`](templates.md) | `/templates` | Docs for templates module | -| [`topic`](topic.md) | `/topic` | Docs for topic module | -| [`user`](user.md) | `/user` | Docs for user module | -| [`worker`](worker.md) | `/worker` | Docs for worker module | +| Module | Path | Description | +| :-------------------------------- | :----------------------- | :--------------------------- | +| [`favicon`](favicon.md) | `//` | Docs for favicon module | +| [`access`](access.md) | `/access-groups` | Docs for access module | +| [`actions`](actions.md) | `/actions` | Docs for actions module | +| [`health`](health.md) | `/api/health` | Docs for health module | +| [`auth`](auth.md) | `/auth` | Docs for auth module | +| [`category`](category.md) | `/categories` | Docs for category module | +| [`file`](file.md) | `/files` | Docs for file module | +| [`hook`](hook.md) | `/hooks/actions` | Docs for hook module | +| [`foxglove`](foxglove.md) | `/integrations/foxglove` | Docs for foxglove module | +| [`metadata`](metadata.md) | `/metadata` | Docs for metadata module | +| [`metadatatype`](metadatatype.md) | `/metadata-types` | Docs for metadatatype module | +| [`mission`](mission.md) | `/missions` | Docs for mission module | +| [`project`](project.md) | `/projects` | Docs for project module | +| [`templates`](templates.md) | `/templates` | Docs for templates module | +| [`topic`](topic.md) | `/topics` | Docs for topic module | +| [`trigger`](trigger.md) | `/triggers` | Docs for trigger module | +| [`user`](user.md) | `/users` | Docs for user module | +| [`worker`](worker.md) | `/workers` | Docs for worker module | diff --git a/backend/package.json b/backend/package.json index 029a2761b..96e1a4d49 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "gen:docs": "node -r ts-node/register -r tsconfig-paths/register ./scripts/generate-openapi.ts" }, "dependencies": { + "@aws-sdk/client-s3": "3.1010.0", "@aws-sdk/client-sts": "3.1005.0", "@kleinkram/api-dto": "workspace:*", "@kleinkram/backend-common": "workspace:*", @@ -80,7 +81,6 @@ "winston-loki": "^6.1.4" }, "devDependencies": { - "@aws-sdk/client-s3": "3.1010.0", "@jest/globals": "^30.2.0", "@nestjs/cli": "^11.0.16", "@nestjs/schematics": "^11.0.9", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 66ed06105..49ddfe04f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,10 +18,10 @@ import { CategoryModule } from './endpoints/category/category.module'; import { FileModule } from './endpoints/file/file.module'; import { HealthModule } from './endpoints/health/health.module'; import { FoxgloveModule } from './endpoints/integrations/foxglove.module'; +import { MetadataModule } from './endpoints/metadata/metadata.module'; import { MissionModule } from './endpoints/mission/mission.module'; import { ProjectModule } from './endpoints/project/project.module'; import { QueueModule } from './endpoints/queue/queue.module'; -import { TagModule } from './endpoints/tag/tag.module'; import { TemplatesModule } from './endpoints/templates/templates.module'; import { TopicModule } from './endpoints/topic/topic.module'; import { TriggerModule } from './endpoints/trigger/trigger.module'; @@ -84,7 +84,7 @@ import { DBDumper } from './services/dbdumper.service'; PassportModule, ActionModule, TemplatesModule, - TagModule, + MetadataModule, WorkerModule, CategoryModule, ScheduleModule.forRoot(), diff --git a/backend/src/decorators.ts b/backend/src/decorators.ts index 95d2107a2..265c257fa 100644 --- a/backend/src/decorators.ts +++ b/backend/src/decorators.ts @@ -1,6 +1,7 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiResponseCommonMetadata, + ApiCreatedResponse as SwaggerApiCreatedResponse, ApiOkResponse as SwaggerApiOkResponse, ApiResponse as SwaggerApiResponse, } from '@nestjs/swagger'; @@ -21,6 +22,14 @@ export const ApiOkResponse = ( ): ReturnType => applyDecorators(OutputDto(options.type), SwaggerApiOkResponse(options)); +export const ApiCreatedResponse = ( + options: ApiResponseCommonMetadata, +): ReturnType => + applyDecorators( + OutputDto(options.type), + SwaggerApiCreatedResponse(options), + ); + export const ApiResponse = ( options: ApiResponseCommonMetadata, ): ReturnType => diff --git a/backend/src/endpoints/access/access.controller.ts b/backend/src/endpoints/access/access.controller.ts index c46f83d5d..85e52d2da 100644 --- a/backend/src/endpoints/access/access.controller.ts +++ b/backend/src/endpoints/access/access.controller.ts @@ -1,5 +1,11 @@ -import { ApiOkResponse, ApiResponse, OutputDto } from '@/decorators'; -import { AccessService } from '@/services/access.service'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiResponse, + OutputDto, +} from '@/decorators'; +import { AccessModificationService } from '@/services/access-modification.service'; +import { AccessQueryService } from '@/services/access-query.service'; import { AccessGroupAuditLogsDto, AccessGroupDto, @@ -16,7 +22,6 @@ import { SetAccessGroupUserExpirationDto, SetAccessGroupUserPermissionsDto, } from '@kleinkram/api-dto'; -import { AccessGroupEntity } from '@kleinkram/backend-common'; import { Body, Controller, @@ -39,9 +44,12 @@ import { UserOnly, } from '../auth/roles.decorator'; -@Controller('access') +@Controller('access-groups') export class AccessController { - constructor(private readonly accessService: AccessService) {} + constructor( + private readonly accessQueryService: AccessQueryService, + private readonly accessModificationService: AccessModificationService, + ) {} @ApiOperation({ summary: 'Get filtered AccessGroups', @@ -58,7 +66,7 @@ export class AccessController { async search( @Query() query: GetFilteredAccessGroupsDto, ): Promise { - return this.accessService.searchAccessGroup( + return this.accessQueryService.searchAccessGroup( query.search, query.type, query.skip, @@ -86,7 +94,7 @@ export class AccessController { @ParameterUID('uuid', 'AccessGroup UUID') uuid: string, @AddUser() user: AuthHeader, ): Promise { - return await this.accessService + return await this.accessQueryService .getAccessGroup(uuid, user.user.uuid) .catch((error: unknown) => { if (error instanceof EntityNotFoundError) { @@ -110,12 +118,12 @@ export class AccessController { async getAuditLogs( @ParameterUID('uuid', 'AccessGroup UUID') uuid: string, ): Promise { - return this.accessService.getAuditLogs(uuid); + return this.accessQueryService.getAuditLogs(uuid); } @Post() @CanCreate() - @ApiOkResponse({ + @ApiCreatedResponse({ type: AccessGroupDto, description: 'Returns the created AccessGroup', }) @@ -128,11 +136,12 @@ export class AccessController { @Body() body: CreateAccessGroupDto, @AddUser() user: AuthHeader, ): Promise { - const accessGroup = await this.accessService.createAccessGroup( - body.name, - user, - ); - return this.accessService.getAccessGroup( + const accessGroup = + await this.accessModificationService.createAccessGroup( + body.name, + user, + ); + return this.accessQueryService.getAccessGroup( accessGroup.uuid, user.user.uuid, ); @@ -141,9 +150,8 @@ export class AccessController { @ApiOperation({ summary: 'Add User to Access Group', }) - @ApiResponse({ - status: 200, - type: AccessGroupEntity, + @ApiCreatedResponse({ + type: AccessGroupDto, description: 'The Access Group the user was added to.', }) @ApiResponse({ @@ -153,13 +161,12 @@ export class AccessController { }) @Post(':uuid/users') @CanEditGroup() - @OutputDto(AccessGroupDto) async addUserToAccessGroup( @ParameterUID('uuid', 'UUID of AccessGroup') uuid: string, @Body() body: AddUserToAccessGroupDto, @AddUser() requestUser: AuthHeader, ): Promise { - await this.accessService + await this.accessModificationService .addUserToAccessGroup( uuid, body.userUuid, @@ -173,7 +180,10 @@ export class AccessController { } throw error; }); - return this.accessService.getAccessGroup(uuid, requestUser.user.uuid); + return this.accessQueryService.getAccessGroup( + uuid, + requestUser.user.uuid, + ); } @ApiOperation({ @@ -192,12 +202,15 @@ export class AccessController { @ParameterUID('userUuid', 'UUID of User to remove') userUuid: string, @AddUser() requestUser: AuthHeader, ): Promise { - await this.accessService.removeUsersFromAccessGroup( + await this.accessModificationService.removeUsersFromAccessGroup( uuid, [userUuid], requestUser, ); - return this.accessService.getAccessGroup(uuid, requestUser.user.uuid); + return this.accessQueryService.getAccessGroup( + uuid, + requestUser.user.uuid, + ); } @ApiOperation({ @@ -216,32 +229,34 @@ export class AccessController { @Body() body: RemoveUsersFromAccessGroupDto, @AddUser() requestUser: AuthHeader, ): Promise { - await this.accessService.removeUsersFromAccessGroup( + await this.accessModificationService.removeUsersFromAccessGroup( uuid, body.userUuids, requestUser, ); - return this.accessService.getAccessGroup(uuid, requestUser.user.uuid); + return this.accessQueryService.getAccessGroup( + uuid, + requestUser.user.uuid, + ); } @ApiOperation({ summary: 'Add Access Group to Project', description: 'Adds an Access Group to a Project with the given rights.', }) - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Returns the Project', type: ProjectDto, }) @Post(':uuid/projects/:projectUuid') @CanWriteProject() - @OutputDto(ProjectDto) async addAccessGroupToProject( @ParameterUID('uuid', 'UUID of AccessGroup') uuid: string, @ParameterUID('projectUuid', 'UUID of Project') projectUuid: string, @Body() body: AddAccessGroupToProjectDto, @AddUser() user: AuthHeader, ): Promise { - return this.accessService.addAccessGroupToProject( + return this.accessModificationService.addAccessGroupToProject( projectUuid, uuid, body.rights, @@ -262,7 +277,7 @@ export class AccessController { @ParameterUID('projectUuid', 'UUID of Project') projectUuid: string, @AddUser() user: AuthHeader, ): Promise { - await this.accessService.removeAccessGroupFromProject( + await this.accessModificationService.removeAccessGroupFromProject( projectUuid, uuid, user, @@ -281,7 +296,7 @@ export class AccessController { async deleteAccessGroup( @ParameterUID('uuid', 'UUID of AccessGroup to be deleted') uuid: string, ): Promise { - await this.accessService.deleteAccessGroup(uuid); + await this.accessModificationService.deleteAccessGroup(uuid); return { success: true }; } @@ -302,7 +317,7 @@ export class AccessController { @Body() body: SetAccessGroupUserExpirationDto, @AddUser() requestUser: AuthHeader, ): Promise { - return this.accessService.setExpireDate( + return this.accessModificationService.setExpireDate( uuid, userUuid, body.expireDate, @@ -326,7 +341,7 @@ export class AccessController { @Body() body: SetAccessGroupUserPermissionsDto, @AddUser() requestUser: AuthHeader, ): Promise { - return this.accessService.setCanEditGroup( + return this.accessModificationService.setCanEditGroup( uuid, userUuid, body.canEditGroup, diff --git a/backend/src/endpoints/access/access.module.ts b/backend/src/endpoints/access/access.module.ts index 668e37823..10048fad6 100644 --- a/backend/src/endpoints/access/access.module.ts +++ b/backend/src/endpoints/access/access.module.ts @@ -1,4 +1,5 @@ -import { AccessService } from '@/services/access.service'; +import { AccessModificationService } from '@/services/access-modification.service'; +import { AccessQueryService } from '@/services/access-query.service'; import { AccessGroupAuditService, AccessGroupEntity, @@ -25,9 +26,17 @@ import { AccessController } from './access.controller'; ]), UserModule, ], - providers: [AccessService, AccessGroupAuditService], + providers: [ + AccessQueryService, + AccessModificationService, + AccessGroupAuditService, + ], controllers: [AccessController], - exports: [AccessService], + exports: [ + AccessQueryService, + AccessModificationService, + AccessGroupAuditService, + ], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class AccessModule {} diff --git a/backend/src/endpoints/action/action.controller.ts b/backend/src/endpoints/action/action.controller.ts index b08425803..6cdeed783 100644 --- a/backend/src/endpoints/action/action.controller.ts +++ b/backend/src/endpoints/action/action.controller.ts @@ -1,6 +1,6 @@ -import { ApiOkResponse, OutputDto } from '@/decorators'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '@/decorators'; import { ActionService } from '@/services/action.service'; -import { FileService } from '@/services/file.service'; +import { FileQueryService } from '@/services/file-query.service'; import { ParameterUuid } from '@/validation/parameter-decorators'; import { ActionDto, @@ -12,6 +12,7 @@ import { PaginatedQueryDto, SubmitActionDto, SubmitActionMulti, + SuccessResponseDto, } from '@kleinkram/api-dto'; import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -20,6 +21,7 @@ import { CanCreateAction, CanCreateActions, CanDeleteAction, + CanReadAction, LoggedIn, } from '../auth/roles.decorator'; @@ -28,13 +30,13 @@ import { export class ActionsController { constructor( private readonly actionService: ActionService, - private readonly fileService: FileService, + private readonly fileQueryService: FileQueryService, ) {} @Post() @CanCreateAction() @ApiOperation({ summary: 'Submit (dispatch) a new action' }) - @ApiOkResponse({ type: ActionSubmitResponseDto }) + @ApiCreatedResponse({ type: ActionSubmitResponseDto }) async create( @Body() dto: SubmitActionDto, @AddUser() user: AuthHeader, @@ -45,7 +47,7 @@ export class ActionsController { @Post('batch') @CanCreateActions() @ApiOperation({ summary: 'Batch submit multiple actions' }) - @ApiOkResponse({ type: [ActionSubmitResponseDto] }) + @ApiCreatedResponse({ type: [ActionSubmitResponseDto] }) async createBatch( @Body() dto: SubmitActionMulti, @AddUser() user: AuthHeader, @@ -65,7 +67,7 @@ export class ActionsController { } @Get(':uuid') - @LoggedIn() + @CanReadAction() @ApiOperation({ summary: 'Get action details' }) @ApiOkResponse({ type: ActionDto }) async findOne(@ParameterUuid('uuid') uuid: string): Promise { @@ -73,7 +75,7 @@ export class ActionsController { } @Get(':uuid/logs') - @LoggedIn() + @CanReadAction() @ApiOperation({ summary: 'Get action logs' }) @ApiOkResponse({ type: ActionLogsDto }) async getLogs( @@ -84,22 +86,22 @@ export class ActionsController { } @Get(':uuid/file-events') - @LoggedIn() + @CanReadAction() @ApiOperation({ summary: 'Get file events triggered by this action' }) @ApiOkResponse({ type: FileEventsDto }) async getFileEvents( @ParameterUuid('uuid') uuid: string, ): Promise { - return this.fileService.getActionFileEvents(uuid); + return this.fileQueryService.getActionFileEvents(uuid); } @Delete(':uuid') @CanDeleteAction() @ApiOperation({ summary: 'Delete a specific action run' }) - @OutputDto(null) + @OutputDto(SuccessResponseDto) async remove( @ParameterUuid('uuid') uuid: string, - ): Promise<{ success: boolean }> { + ): Promise { await this.actionService.delete(uuid); return { success: true }; } diff --git a/backend/src/endpoints/auth/auth.controller.ts b/backend/src/endpoints/auth/auth.controller.ts index 21ddafcd9..5706a94b8 100644 --- a/backend/src/endpoints/auth/auth.controller.ts +++ b/backend/src/endpoints/auth/auth.controller.ts @@ -61,7 +61,7 @@ export class AuthController { @Get('github/callback') @UseGuards(AuthGuard('github')) - @OutputDto(null) // TODO: type API response + @OutputDto(null) // eslint-disable-next-line @typescript-eslint/require-await async githubAuthCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -74,7 +74,7 @@ export class AuthController { @Get('google/callback') @UseGuards(AuthGuard('google')) - @OutputDto(null) // TODO: type API response + @OutputDto(null) googleAuthRedirect( @Req() request: Request, @Res() response: Response, @@ -84,7 +84,7 @@ export class AuthController { @Get('fake-oauth/callback') @UseGuards(AuthGuard(Providers.FakeOAuth)) - @OutputDto(null) // TODO: type API response + @OutputDto(null) fakeOAuthAuthRedirect( @Req() request: Request, @Res() response: Response, @@ -185,14 +185,14 @@ export class AuthController { @Get('validate-token') @UserOnly() - @OutputDto(null) // TODO: type API response + @OutputDto(null) validateToken(@Res() response: Response): void { // If we reach here, the token is valid response.status(200).json({ message: 'Token is valid' }); } @Post('refresh-token') - @OutputDto(null) // TODO: type API response + @OutputDto(null) async refreshToken(@Req() request: Request, @Res() response: Response) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const refreshToken = request.cookies[CookieNames.REFRESH_TOKEN]; @@ -238,7 +238,7 @@ export class AuthController { } @Post('logout') - @OutputDto(null) // TODO: type API response + @OutputDto(null) logout(@Res() response: Response): void { response.cookie(CookieNames.AUTH_TOKEN, '', { httpOnly: false, diff --git a/backend/src/endpoints/auth/guards/action.guards.ts b/backend/src/endpoints/auth/guards/action.guards.ts index f81ac7826..0d8cb1001 100644 --- a/backend/src/endpoints/auth/guards/action.guards.ts +++ b/backend/src/endpoints/auth/guards/action.guards.ts @@ -60,7 +60,12 @@ export class ReadActionGuard extends BaseGuard { async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); - const actionUUID = request.query.uuid as string | undefined; + const params = request.params as { uuid?: string } | undefined; + const body = request.body as ActionBody | undefined; + const actionUUID = + (request.query.uuid as string | undefined) ?? + params?.uuid ?? + body?.actionUUID; if (!actionUUID) { return false; // Deny access if UUID not provided diff --git a/backend/src/endpoints/auth/guards/file.guards.ts b/backend/src/endpoints/auth/guards/file.guards.ts index a7b435af6..aed27d2cd 100644 --- a/backend/src/endpoints/auth/guards/file.guards.ts +++ b/backend/src/endpoints/auth/guards/file.guards.ts @@ -6,6 +6,7 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { BaseGuard } from './base.guards'; interface FileBody { @@ -19,122 +20,29 @@ interface FileParameters { } @Injectable() -export class DeleteFileGuard extends BaseGuard { - constructor(private fileGuardService: FileGuardService) { +export class FileAccessGuard extends BaseGuard { + constructor( + private fileGuardService: FileGuardService, + private reflector: Reflector, + ) { super(); } async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); + const requiredRight = + this.reflector.get( + 'accessRight', + context.getHandler(), + ) ?? AccessGroupRights.READ; + const body = request.body as FileBody | undefined; const params = request.params as FileParameters | undefined; - let fileUUID = request.query.uuid as string | undefined; - - if (!fileUUID && body) { - fileUUID = body.uuid; - } - - if (!fileUUID && params) { - fileUUID = params.uuid; - } - - if (!fileUUID) { - return false; // Deny access if UUID not provided - } - - if (apiKey) { - return this.fileGuardService.canKeyAccessFile( - apiKey, - fileUUID, - AccessGroupRights.DELETE, - ); - } - return this.fileGuardService.canAccessFile( - user, - fileUUID, - AccessGroupRights.DELETE, - ); - } -} - -@Injectable() -export class ReadFileGuard extends BaseGuard { - constructor(private fileGuardService: FileGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const params = request.params as FileParameters; - const fileUUID = - (request.query.uuid as string | undefined) ?? params.uuid; - - if (!fileUUID) { - return false; // Deny access if UUID not provided - } - - if (apiKey) { - return this.fileGuardService.canKeyAccessFile( - apiKey, - fileUUID, - AccessGroupRights.READ, - ); - } - return this.fileGuardService.canAccessFile( - user, - fileUUID, - AccessGroupRights.READ, - ); - } -} - -@Injectable() -export class ReadFileByNameGuard extends BaseGuard { - constructor(private fileGuardService: FileGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const filename = request.query.name as string | undefined; - - if (!filename) { - return false; // Deny access if filename not provided - } - - if (apiKey) { - return this.fileGuardService.canKeyAccessFileByName( - apiKey, - filename, - AccessGroupRights.READ, - ); - } - return this.fileGuardService.canAccessFileByName( - user, - filename, - AccessGroupRights.READ, - ); - } -} - -@Injectable() -export class WriteFileGuard extends BaseGuard { - constructor(private fileGuardService: FileGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const body = request.body as FileBody; - const params = request.params as FileParameters; const fileUUID = (request.query.uuid as string | undefined) ?? - body.uuid ?? - params.uuid; + body?.uuid ?? + params?.uuid; if (!fileUUID) { return false; // Deny access if UUID not provided @@ -144,13 +52,13 @@ export class WriteFileGuard extends BaseGuard { return this.fileGuardService.canKeyAccessFile( apiKey, fileUUID, - AccessGroupRights.WRITE, + requiredRight, ); } return this.fileGuardService.canAccessFile( user, fileUUID, - AccessGroupRights.WRITE, + requiredRight, ); } } diff --git a/backend/src/endpoints/auth/guards/index.ts b/backend/src/endpoints/auth/guards/index.ts index 40492bfbe..8cd891e19 100644 --- a/backend/src/endpoints/auth/guards/index.ts +++ b/backend/src/endpoints/auth/guards/index.ts @@ -8,36 +8,18 @@ export { } from './base.guards'; // Project guards -export { - CreateGuard, - CreateInProjectByBodyGuard, - DeleteProjectGuard, - ReadProjectByNameGuard, - ReadProjectGuard, - WriteProjectGuard, -} from './project.guards'; +export { CreateGuard, ProjectAccessGuard } from './project.guards'; // Mission guards export { - AddTagGuard, - CanDeleteMissionGuard, CanReadManyMissionsGuard, - CreateInMissionByBodyGuard, DeleteTagGuard, + MissionAccessGuard, MoveMissionToProjectGuard, - ReadMissionByNameGuard, - ReadMissionGuard, - WriteMissionByBodyGuard, } from './mission.guards'; // File guards -export { - DeleteFileGuard, - MoveFilesGuard, - ReadFileByNameGuard, - ReadFileGuard, - WriteFileGuard, -} from './file.guards'; +export { FileAccessGuard, MoveFilesGuard } from './file.guards'; // Action guards export { diff --git a/backend/src/endpoints/auth/guards/mission.guards.ts b/backend/src/endpoints/auth/guards/mission.guards.ts index fe9146318..b455b42d1 100644 --- a/backend/src/endpoints/auth/guards/mission.guards.ts +++ b/backend/src/endpoints/auth/guards/mission.guards.ts @@ -1,12 +1,12 @@ import { MissionGuardService } from '@/endpoints/auth/mission-guard.service'; import { ProjectGuardService } from '@/services/project-guard.service'; -import { AccessGroupRights, UserRole } from '@kleinkram/shared'; +import { AccessGroupRights } from '@kleinkram/shared'; import { - BadRequestException, ExecutionContext, Injectable, UnauthorizedException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { BaseGuard } from './base.guards'; interface MissionBody { @@ -21,15 +21,34 @@ interface TagParameters { } @Injectable() -export class ReadMissionGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { +export class MissionAccessGuard extends BaseGuard { + constructor( + private missionGuardService: MissionGuardService, + private reflector: Reflector, + ) { super(); } async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); - const missionUUID = request.query.uuid as string | undefined; + const requiredRight = + this.reflector.get( + 'accessRight', + context.getHandler(), + ) ?? AccessGroupRights.READ; + + const body = request.body as MissionBody | undefined; + const params = request.params as { uuid?: string } | undefined; + const missionUUID = + params?.uuid ?? + (request.query.uuid as string | undefined) ?? + (request.query.missionUuid as string | undefined) ?? + (request.query.missionUUID as string | undefined) ?? + body?.missionUUID ?? + body?.missionUuid ?? + body?.uuid ?? + body?.mission; if (!missionUUID) { return false; // Deny access if UUID not provided @@ -39,11 +58,15 @@ export class ReadMissionGuard extends BaseGuard { return this.missionGuardService.canKeyAccessMission( apiKey, missionUUID, - AccessGroupRights.READ, + requiredRight, ); } - return this.missionGuardService.canAccessMission(user, missionUUID); + return this.missionGuardService.canAccessMission( + user, + missionUUID, + requiredRight, + ); } } @@ -76,194 +99,24 @@ export class CanReadManyMissionsGuard extends BaseGuard { } } -@Injectable() -export class ReadMissionByNameGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const missionName = request.query.name as string | undefined; - const projectUuid = request.query.projectUUID as string | undefined; - - if (!missionName || !projectUuid) { - return false; // Deny access if required parameters not provided - } - - if (apiKey) { - return this.missionGuardService.canKeyAccessMissionByName( - apiKey, - missionName, - projectUuid, - AccessGroupRights.READ, - ); - } - return this.missionGuardService.canAccessMissionByName( - user, - missionName, - projectUuid, - ); - } -} - -@Injectable() -export class CreateInMissionByBodyGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const body = request.body as MissionBody; - const missionUUID = body.missionUUID ?? body.missionUuid; - - if (user.role === UserRole.ADMIN) { - return true; - } - - if (!missionUUID) { - return false; // Deny access if UUID not provided - } - - if (apiKey) { - return this.missionGuardService.canKeyAccessMission( - apiKey, - missionUUID, - AccessGroupRights.CREATE, - ); - } - return this.missionGuardService.canAccessMission( - user, - missionUUID, - AccessGroupRights.CREATE, - ); - } -} - -@Injectable() -export class WriteMissionByBodyGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const body = request.body as MissionBody; - const missionUUID = body.missionUUID ?? body.missionUuid; - - if (user.role === UserRole.ADMIN) { - return true; - } - - if (!missionUUID) { - return false; // Deny access if UUID not provided - } - - if (apiKey) { - return this.missionGuardService.canKeyAccessMission( - apiKey, - missionUUID, - AccessGroupRights.WRITE, - ); - } - return this.missionGuardService.canAccessMission( - user, - missionUUID, - AccessGroupRights.WRITE, - ); - } -} - -@Injectable() -export class CanDeleteMissionGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - const body = request.body as MissionBody | undefined; - const params = request.params as TagParameters | undefined; - - let missionUUID: string | undefined; - - if (body) { - missionUUID = body.uuid ?? body.missionUUID ?? body.missionUuid; - } - - if (user.role === UserRole.ADMIN) { - return true; - } - - if (!missionUUID && params) { - missionUUID = params.uuid; - } - - if (!missionUUID) { - throw new BadRequestException( - 'Mission UUID not provided in body or params', - ); - } - - if (apiKey) { - return this.missionGuardService.canKeyAccessMission( - apiKey, - missionUUID, - AccessGroupRights.DELETE, - ); - } - return this.missionGuardService.canAccessMission( - user, - missionUUID, - AccessGroupRights.DELETE, - ); - } -} - -@Injectable() -export class AddTagGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const body = request.body as MissionBody; - const missionUUID = body.mission; - - if (!missionUUID) { - return false; // Deny access if mission UUID not provided - } - - if (apiKey) { - return this.missionGuardService.canKeyAccessMission( - apiKey, - missionUUID, - AccessGroupRights.WRITE, - ); - } - return this.missionGuardService.canAccessMission( - user, - missionUUID, - AccessGroupRights.WRITE, - ); - } -} - @Injectable() export class DeleteTagGuard extends BaseGuard { - constructor(private missionGuardService: MissionGuardService) { + constructor( + private missionGuardService: MissionGuardService, + private reflector: Reflector, + ) { super(); } async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); + const requiredRight = + this.reflector.get( + 'accessRight', + context.getHandler(), + ) ?? AccessGroupRights.DELETE; + const body = request.body as MissionBody | undefined; const params = request.params as TagParameters; const tagUuid = body?.uuid ?? params.uuid; @@ -276,13 +129,15 @@ export class DeleteTagGuard extends BaseGuard { return this.missionGuardService.canKeyTagMission( apiKey, tagUuid, - AccessGroupRights.DELETE, + requiredRight, ); } return this.missionGuardService.canTagMission( user, tagUuid, - AccessGroupRights.WRITE, + requiredRight === AccessGroupRights.DELETE + ? AccessGroupRights.WRITE + : requiredRight, ); } } @@ -299,7 +154,9 @@ export class MoveMissionToProjectGuard extends BaseGuard { async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); - const missionUUID = request.query.missionUUID as string | undefined; + const params = request.params as { uuid?: string } | undefined; + const missionUUID = + params?.uuid ?? (request.query.missionUUID as string | undefined); const projectUUID = request.query.projectUUID as string | undefined; if (!missionUUID || !projectUUID) { diff --git a/backend/src/endpoints/auth/guards/project.guards.ts b/backend/src/endpoints/auth/guards/project.guards.ts index 80d408faa..ec1a75677 100644 --- a/backend/src/endpoints/auth/guards/project.guards.ts +++ b/backend/src/endpoints/auth/guards/project.guards.ts @@ -6,6 +6,7 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { BaseGuard } from './base.guards'; interface ProjectBody { @@ -19,165 +20,69 @@ interface ProjectParameters { } @Injectable() -export class ReadProjectGuard extends BaseGuard { - constructor(private projectGuardService: ProjectGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - const params = request.params as ProjectParameters; - const projectUUID = - (request.query.uuid as string | undefined) ?? - params.projectUuid ?? - params.uuid; - - if (!projectUUID) { - return false; // Deny access if UUID not provided - } - - if (apiKey) { - // Check if this is an action API key - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime type safety for apiKey.key_type - if (apiKey.key_type === KeyTypes.ACTION) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime null check: mission and project may not be loaded - const missionProject = apiKey.mission?.project; - - if (missionProject?.uuid) { - // Action keys can only access their associated project - if (missionProject.uuid !== projectUUID) { - throw new ForbiddenException( - 'Action key cannot access this project', - ); - } - return true; - } - } - // CLI keys and other keys cannot read projects - throw new UnauthorizedException('CLI Keys cannot read projects'); - } - - return this.projectGuardService.canAccessProject(user, projectUUID); - } -} - -@Injectable() -export class ReadProjectByNameGuard extends BaseGuard { - constructor(private projectGuardService: ProjectGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - if (apiKey) { - throw new UnauthorizedException('CLI Keys cannot read projects'); - } - - const projectName = request.query.name as string | undefined; - - if (!projectName) { - return false; // Deny access if project name not provided - } - - return this.projectGuardService.canAccessProjectByName( - user, - projectName, - ); - } -} - -@Injectable() -export class CreateInProjectByBodyGuard extends BaseGuard { - constructor(private projectGuardService: ProjectGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - - if (apiKey) { - throw new UnauthorizedException('CLI Keys cannot read projects'); - } - - const body = request.body as ProjectBody | undefined; - const projectUUID = body?.projectUUID; - - if (!projectUUID) { - return false; // Deny access if project UUID not provided - } - - return this.projectGuardService.canAccessProject( - user, - projectUUID, - AccessGroupRights.CREATE, - ); - } -} - -@Injectable() -export class WriteProjectGuard extends BaseGuard { - constructor(private projectGuardService: ProjectGuardService) { +export class ProjectAccessGuard extends BaseGuard { + constructor( + private projectGuardService: ProjectGuardService, + private reflector: Reflector, + ) { super(); } async canActivate(context: ExecutionContext): Promise { const { user, apiKey, request } = await this.getUser(context); - if (apiKey) { - throw new UnauthorizedException('CLI Keys cannot write projects'); - } + const requiredRight = + this.reflector.get( + 'accessRight', + context.getHandler(), + ) ?? AccessGroupRights.READ; const body = request.body as ProjectBody | undefined; const params = request.params as ProjectParameters | undefined; const projectUUID = + (request.query.projectUuid as string | undefined) ?? (request.query.uuid as string | undefined) ?? params?.projectUuid ?? - body?.uuid ?? - params?.uuid; + params?.uuid ?? + body?.projectUUID ?? + body?.uuid; if (!projectUUID) { return false; // Deny access if UUID not provided } - return this.projectGuardService.canAccessProject( - user, - projectUUID, - AccessGroupRights.WRITE, - ); - } -} - -@Injectable() -export class DeleteProjectGuard extends BaseGuard { - constructor(private projectGuardService: ProjectGuardService) { - super(); - } - - async canActivate(context: ExecutionContext): Promise { - const { user, apiKey, request } = await this.getUser(context); - if (apiKey) { - throw new UnauthorizedException('CLI Keys cannot delete projects'); - } - - const body = request.body as ProjectBody | undefined; - const params = request.params as ProjectParameters | undefined; - const projectUUID = - (request.query.uuid as string | undefined) ?? - params?.projectUuid ?? - params?.uuid ?? - body?.uuid; - - if (!projectUUID) { - return false; // Deny access if UUID not provided + if (requiredRight === AccessGroupRights.READ) { + // Check if this is an action API key + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime type safety for apiKey.key_type + if (apiKey.key_type === KeyTypes.ACTION) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime null check: mission and project may not be loaded + const missionProject = apiKey.mission?.project; + + if (missionProject?.uuid) { + // Action keys can only access their associated project + if (missionProject.uuid !== projectUUID) { + throw new ForbiddenException( + 'Action key cannot access this project', + ); + } + return true; + } + } + throw new UnauthorizedException( + 'CLI Keys cannot read projects', + ); + } else { + throw new UnauthorizedException( + `CLI Keys cannot perform write/delete/create on projects`, + ); + } } return this.projectGuardService.canAccessProject( user, projectUUID, - AccessGroupRights.DELETE, + requiredRight, ); } } diff --git a/backend/src/endpoints/auth/roles.decorator.ts b/backend/src/endpoints/auth/roles.decorator.ts index 1c6460f6c..953dbb6c5 100644 --- a/backend/src/endpoints/auth/roles.decorator.ts +++ b/backend/src/endpoints/auth/roles.decorator.ts @@ -1,5 +1,6 @@ import { ApiResponse } from '@/decorators'; import { UnauthorizedExceptionDto } from '@kleinkram/api-dto'; +import { AccessGroupRights } from '@kleinkram/shared'; import { applyDecorators, ForbiddenException, @@ -7,35 +8,23 @@ import { UseGuards, } from '@nestjs/common'; import { - AddTagGuard, AdminOnlyGuard, - CanDeleteMissionGuard, CanEditGroupByGroupUuid, CanModifyTriggerGuard, CanReadManyMissionsGuard, CreateActionGuard, CreateActionsGuard, CreateGuard, - CreateInMissionByBodyGuard, - CreateInProjectByBodyGuard, DeleteActionGuard, - DeleteFileGuard, - DeleteProjectGuard, DeleteTagGuard, + FileAccessGuard, LoggedInUserGuard, + MissionAccessGuard, MoveFilesGuard, MoveMissionToProjectGuard, + ProjectAccessGuard, ReadActionGuard, - ReadFileByNameGuard, - ReadFileGuard, - ReadMissionByNameGuard, - ReadMissionGuard, - ReadProjectByNameGuard, - ReadProjectGuard, UserGuard, - WriteFileGuard, - WriteMissionByBodyGuard, - WriteProjectGuard, } from './guards'; // Logged-in user route decorator @@ -80,21 +69,8 @@ export function AdminOnly() { export function CanReadProject() { return applyDecorators( - SetMetadata('CanReadProject', true), - UseGuards(ReadProjectGuard), - ApiResponse({ - status: 401, - type: UnauthorizedExceptionDto, - description: - 'User does not have Read permissions on the specified project.', - }), - ); -} - -export function CanReadProjectByName() { - return applyDecorators( - SetMetadata('CanReadProjectByName', true), - UseGuards(ReadProjectByNameGuard), + SetMetadata('accessRight', AccessGroupRights.READ), + UseGuards(ProjectAccessGuard), ApiResponse({ status: 401, type: UnauthorizedExceptionDto, @@ -106,8 +82,8 @@ export function CanReadProjectByName() { export function CanCreateInProjectByBody() { return applyDecorators( - SetMetadata('CanCreateInProjectByBody', true), - UseGuards(CreateInProjectByBodyGuard), + SetMetadata('accessRight', AccessGroupRights.CREATE), + UseGuards(ProjectAccessGuard), ApiResponse({ status: 401, type: UnauthorizedExceptionDto, @@ -119,8 +95,8 @@ export function CanCreateInProjectByBody() { export function CanWriteProject() { return applyDecorators( - SetMetadata('CanWriteProject', true), - UseGuards(WriteProjectGuard), + SetMetadata('accessRight', AccessGroupRights.WRITE), + UseGuards(ProjectAccessGuard), ApiResponse({ status: 401, type: UnauthorizedExceptionDto, @@ -132,8 +108,8 @@ export function CanWriteProject() { export function CanDeleteProject() { return applyDecorators( - SetMetadata('CanDeleteProject', true), - UseGuards(DeleteProjectGuard), + SetMetadata('accessRight', AccessGroupRights.DELETE), + UseGuards(ProjectAccessGuard), ApiResponse({ status: 401, type: UnauthorizedExceptionDto, @@ -158,21 +134,8 @@ export function CanCreate() { export function CanReadMission() { return applyDecorators( - SetMetadata('CanReadMission', true), - UseGuards(ReadMissionGuard), - ApiResponse({ - status: 403, - type: ForbiddenException, - description: - 'User does not have Read permissions on the specified project.', - }), - ); -} - -export function CanReadMissionByName() { - return applyDecorators( - SetMetadata('CanReadMission', true), - UseGuards(ReadMissionByNameGuard), + SetMetadata('accessRight', AccessGroupRights.READ), + UseGuards(MissionAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -197,21 +160,8 @@ export function CanMoveMission() { export function CanReadFile() { return applyDecorators( - SetMetadata('CanReadFile', true), - UseGuards(ReadFileGuard), - ApiResponse({ - status: 403, - type: ForbiddenException, - description: - 'User does not have Read permissions on the specified project.', - }), - ); -} - -export function CanReadFileByName() { - return applyDecorators( - SetMetadata('CanReadFileByName', true), - UseGuards(ReadFileByNameGuard), + SetMetadata('accessRight', AccessGroupRights.READ), + UseGuards(FileAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -223,8 +173,8 @@ export function CanReadFileByName() { export function CanWriteFile() { return applyDecorators( - SetMetadata('CanWriteFile', true), - UseGuards(WriteFileGuard), + SetMetadata('accessRight', AccessGroupRights.WRITE), + UseGuards(FileAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -249,8 +199,8 @@ export function CanMoveFiles() { export function CanCreateInMissionByBody() { return applyDecorators( - SetMetadata('CanReadMissionByBody', true), - UseGuards(CreateInMissionByBodyGuard), + SetMetadata('accessRight', AccessGroupRights.CREATE), + UseGuards(MissionAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -262,8 +212,8 @@ export function CanCreateInMissionByBody() { export function CanWriteMissionByBody() { return applyDecorators( - SetMetadata('CanReadMissionByBody', true), - UseGuards(WriteMissionByBodyGuard), + SetMetadata('accessRight', AccessGroupRights.WRITE), + UseGuards(MissionAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -275,8 +225,8 @@ export function CanWriteMissionByBody() { export function CanDeleteMission() { return applyDecorators( - SetMetadata('CanReadMissionByBody', true), - UseGuards(CanDeleteMissionGuard), + SetMetadata('accessRight', AccessGroupRights.DELETE), + UseGuards(MissionAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -288,8 +238,8 @@ export function CanDeleteMission() { export function CanDeleteFile() { return applyDecorators( - SetMetadata('CanDeleteFile', true), - UseGuards(DeleteFileGuard), + SetMetadata('accessRight', AccessGroupRights.DELETE), + UseGuards(FileAccessGuard), ApiResponse({ status: 403, type: ForbiddenException, @@ -353,8 +303,8 @@ export function CanDeleteAction() { export function CanAddTag() { return applyDecorators( - SetMetadata('CanAddTag', true), - UseGuards(AddTagGuard), + SetMetadata('accessRight', AccessGroupRights.WRITE), + UseGuards(MissionAccessGuard), ApiResponse({ status: 401, type: UnauthorizedExceptionDto, @@ -366,7 +316,7 @@ export function CanAddTag() { export function CanDeleteTag() { return applyDecorators( - SetMetadata('CanDeleteTag', true), + SetMetadata('accessRight', AccessGroupRights.DELETE), UseGuards(DeleteTagGuard), ApiResponse({ status: 401, diff --git a/backend/src/endpoints/category/category.controller.ts b/backend/src/endpoints/category/category.controller.ts index d1907423e..728ca5fdb 100644 --- a/backend/src/endpoints/category/category.controller.ts +++ b/backend/src/endpoints/category/category.controller.ts @@ -1,7 +1,11 @@ -import { ApiOkResponse, OutputDto } from '@/decorators'; +import { ApiCreatedResponse, ApiOkResponse } from '@/decorators'; import { CategoryService } from '@/services/category.service'; import { QueryOptionalString, QueryUUID } from '@/validation/query-decorators'; -import { CategoriesDto } from '@kleinkram/api-dto'; +import { + CategoriesDto, + CategoryDto, + SuccessResponseDto, +} from '@kleinkram/api-dto'; import { BodyString, BodyUUID, BodyUUIDArray } from '@kleinkram/validation'; import { Controller, Get, Post } from '@nestjs/common'; import { AddUser, AuthHeader } from '../auth/parameter-decorator'; @@ -11,27 +15,30 @@ import { CanWriteMissionByBody, } from '../auth/roles.decorator'; -@Controller('category') +@Controller('categories') export class CategoryController { constructor(private readonly categoryService: CategoryService) {} - @Get('all') + @Get() @CanReadProject() @ApiOkResponse({ description: 'Get all categories in a project', type: CategoriesDto, }) async getAll( - @QueryUUID('uuid', 'Project UUID') uuid: string, + @QueryUUID('projectUuid', 'Project UUID') projectUuid: string, @QueryOptionalString('filter', 'Filter by Category name') filter?: string, ): Promise { - return this.categoryService.getAll(uuid, filter); + return this.categoryService.getAll(projectUuid, filter); } - @Post('create') + @Post() @CanCreateInProjectByBody() - @OutputDto(null) // TODO: type API response + @ApiCreatedResponse({ + description: 'Returns the created category', + type: CategoryDto, + }) async createCategory( @BodyString('name', 'Category Name') name: string, @AddUser() user: AuthHeader, @@ -41,9 +48,12 @@ export class CategoryController { } // this should be moved to the file controller - @Post('addMany') + @Post('add-many') @CanWriteMissionByBody() - @OutputDto(null) // TODO: type API response + @ApiCreatedResponse({ + description: 'Categories added successfully', + type: SuccessResponseDto, + }) async addManyCategories( @BodyUUID('missionUUID', 'Mission UUID') missionUUID: string, @BodyUUIDArray('files', 'List of File UUID where Categries are added') diff --git a/backend/src/endpoints/file/file.controller.ts b/backend/src/endpoints/file/file.controller.ts index d8e5a76cd..e9b6f0cc8 100644 --- a/backend/src/endpoints/file/file.controller.ts +++ b/backend/src/endpoints/file/file.controller.ts @@ -1,16 +1,13 @@ -import { ApiOkResponse, OutputDto } from '@/decorators'; -import { FileService } from '@/services/file.service'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '@/decorators'; +import { FileLifecycleService } from '@/services/file-lifecycle.service'; +import { FileQueryService } from '@/services/file-query.service'; +import { FileStorageService } from '@/services/file-storage.service'; import { QueueService } from '@/services/queue.service'; import { QueryBoolean, QueryDate, - QueryOptionalDate, - QueryOptionalRecord, QueryOptionalString, - QueryOptionalUUID, QuerySkip, - QuerySortBy, - QuerySortDirection, QueryString, QueryTake, QueryUUID, @@ -50,7 +47,6 @@ import { BodyString, BodyUUID, BodyUUIDArray, - isValidFileName, } from '@kleinkram/validation'; import { BadRequestException, @@ -58,6 +54,7 @@ import { Controller, Delete, Get, + Patch, Post, Put, Query, @@ -80,12 +77,14 @@ import { } from '../auth/roles.decorator'; import { FoxgloveService } from '@/services/foxglove.service'; -import { FileSource, HealthStatus } from '@kleinkram/shared'; +import { FileSource } from '@kleinkram/shared'; @Controller(['files']) export class FileController { constructor( - private readonly fileService: FileService, + private readonly fileQueryService: FileQueryService, + private readonly fileStorageService: FileStorageService, + private readonly fileLifecycleService: FileLifecycleService, private readonly queueService: QueueService, private readonly foxgloveService: FoxgloveService, ) {} @@ -100,126 +99,48 @@ export class FileController { @Query() query: FileQueryDto, @AddUser() auth: AuthHeader, ): Promise { + let _missionUUID = query.missionUUID; + if (auth.apiKey) { + _missionUUID = auth.apiKey.mission.uuid; + } + + const projectUuids = + query.projectUuids ?? + (query.projectUUID ? [query.projectUUID] : []); + const missionUuids = + query.missionUuids ?? (_missionUUID ? [_missionUUID] : []); + // we pre-check the access to give a proper error message // the actual findMany method will check access again per file - await this.fileService.checkResourceAccess( - query.projectUuids ?? [], - query.missionUuids ?? [], + await this.fileQueryService.checkResourceAccess( + projectUuids, + missionUuids, auth.user.uuid, ); // also check access by patterns - await this.fileService.checkResourceAccessByName( + await this.fileQueryService.checkResourceAccessByName( query.projectPatterns ?? [], query.missionPatterns ?? [], auth.user.uuid, ); // now fetch files, we only query files we have access to - return await this.fileService.findMany( - query.projectUuids ?? [], - query.projectPatterns ?? [], - query.missionUuids ?? [], - query.missionPatterns ?? [], - query.fileUuids ?? [], - query.filePatterns ?? [], - query.metadata ?? {}, - query.sortBy, - query.sortOrder, - query.take, - query.skip, + return await this.fileQueryService.findMany( + query, auth.user.uuid, - ); - } - - @Get('filtered') - @LoggedIn() - @ApiOkResponse({ - description: 'Filtered Files', - type: FilesDto, - }) - async filteredFiles( - @QueryOptionalString('fileName', 'Filter for Filename') - fileName: string, - @QueryOptionalUUID('projectUUID', 'UUID of Project to filter by') - projectUUID: string, - @QueryOptionalUUID('missionUUID', 'UUID of Mission to filter by') - missionUUID: string, - @QueryOptionalDate( - 'startDate', - 'Date specifying the start of the filtered time range', - ) - startDate: Date | undefined, - @QueryOptionalDate( - 'endDate', - 'Date specifying the end of the filtered time range', - ) - endDate: Date | undefined, - @QueryOptionalString('topics', 'Name of Topics (coma separated)') - topics: string, - @QueryOptionalString( - 'messageDatatypes', - 'Message datatypes to filter by (coma separated). If multiple are given, ' + - 'files containing any of the datatypes are returned (OR).', - ) - messageDatatypes: string, - @QueryOptionalString( - 'fileTypes', - 'File types to filter by (coma separated)', - ) - fileTypes: string, - @QueryOptionalString( - 'categories', - 'Categories to filter by (coma separated)', - ) - categories: string, - @QueryBoolean( - 'matchAllTopics', - 'Returned File needs all specified topics (true) or any specified topics (false)', - ) - matchAllTopics: boolean, - @QueryOptionalRecord('tags', 'Dictionary Tagtype name to Tag value') // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: Record, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - @QuerySortBy('sort') sort: string, - @QuerySortDirection('sortDirection') sortDirection: 'ASC' | 'DESC', - @QueryOptionalString('health', 'File health') health: HealthStatus, - @AddUser() auth: AuthHeader, - ): Promise { - let _missionUUID = missionUUID; - if (auth.apiKey) { - _missionUUID = auth.apiKey.mission.uuid; - } - return await this.fileService.findFiltered( - fileName, - projectUUID, _missionUUID, - startDate, - endDate, - topics, - messageDatatypes, - categories, - matchAllTopics, - fileTypes, - tags, // todo check if this is correct - auth.user.uuid, - Number.parseInt(String(take)), // TODO: fix - Number.parseInt(String(skip)), // TODO: fix - sort, - sortDirection, - health, ); } - @Get('download') + @Get(':uuid/download') @CanReadFile() @ApiOkResponse({ description: 'Download link', type: DownloadResponseDto, }) async download( - @QueryUUID('uuid', 'File UUID') uuid: string, + @ParameterUID('uuid') uuid: string, @QueryBoolean( 'expires', 'Whether the download link should stay valid for on week (false) or 4h (true)', @@ -234,7 +155,7 @@ export class FileController { @AddUser() auth: AuthHeader, ): Promise { logger.debug(`download ${uuid}: expires=${expires.toString()}`); - const url = await this.fileService.generateDownload( + const url = await this.fileStorageService.generateDownload( uuid, expires, previewOnly, @@ -245,16 +166,16 @@ export class FileController { } // TODO: replace this with /file/:uuid - @Get('one') + @Get(':uuid') @CanReadFile() @ApiOkResponse({ description: 'File', type: FileWithTopicDto, }) async getFileById( - @QueryUUID('uuid', 'File UUID') uuid: string, + @ParameterUID('uuid') uuid: string, ): Promise { - const file = await this.fileService.findOne(uuid); + const file = await this.fileQueryService.findOne(uuid); return plainToInstance(FileWithTopicDto, file, { excludeExtraneousValues: true, }); @@ -271,7 +192,7 @@ export class FileController { @Body() dto: UpdateFile, @AddUser() auth: AuthHeader, ): Promise { - const file = await this.fileService.update( + const file = await this.fileLifecycleService.update( uuid, dto, auth.user, @@ -282,7 +203,7 @@ export class FileController { }); } - @Post('moveFiles') + @Patch() @CanMoveFiles() @ApiOkResponse({ description: 'Move Files Response', @@ -294,7 +215,7 @@ export class FileController { @BodyUUID('missionUUID', 'UUID of target Mission') missionUUID: string, @AddUser() auth: AuthHeader, ): Promise { - await this.fileService.moveFiles( + await this.fileLifecycleService.moveFiles( fileUUIDs, missionUUID, auth.user, @@ -313,7 +234,7 @@ export class FileController { @QueryUUID('uuid', 'Mission UUID to search in') uuid: string, @QueryString('filename', 'Filename searched for') name: string, ): Promise { - const file = await this.fileService.findOneByName(uuid, name); + const file = await this.fileQueryService.findOneByName(uuid, name); return plainToInstance(FileDto, file, { excludeExtraneousValues: true, }); @@ -326,7 +247,11 @@ export class FileController { @ParameterUID('uuid') uuid: string, @AddUser() auth: AuthHeader, ): Promise { - await this.fileService.deleteFile(uuid, auth.user, auth.apiKey?.action); + await this.fileLifecycleService.deleteFile( + uuid, + auth.user, + auth.apiKey?.action, + ); return { success: true }; } @@ -337,7 +262,7 @@ export class FileController { }) @LoggedIn() async getStorage(): Promise { - return this.fileService.getStorage(); + return this.fileStorageService.getStorage(); } @Get('isUploading') @@ -351,13 +276,15 @@ export class FileController { @AddUser() auth: AuthHeader, ): Promise { return { - isUploading: await this.fileService.isUploading(auth.user.uuid), + isUploading: await this.fileLifecycleService.isUploading( + auth.user.uuid, + ), }; } @Post('temporaryAccess') @CanCreateInMissionByBody() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Temporary file access', type: TemporaryFileAccessesDto, }) @@ -375,24 +302,7 @@ export class FileController { } } - const invalidFiles: { filename: string; error: string }[] = []; - for (const filename of body.filenames) { - if (!isValidFileName(filename)) { - invalidFiles.push({ - filename, - error: `Filename "${filename}" is not valid!`, - }); - } - } - - if (invalidFiles.length > 0) { - throw new BadRequestException({ - message: 'Validation failed', - errors: invalidFiles, - }); - } - - return await this.fileService.getTemporaryAccess( + return await this.fileLifecycleService.getTemporaryAccess( body.filenames, body.missionUUID, auth.user.uuid, @@ -401,7 +311,7 @@ export class FileController { ); } - @Post('cancelUpload') + @Delete('uploads') @UserOnly() //Push back authentication to the queue to accelerate the request @OutputDto(CancelUploadResponseDto) async cancelUpload( @@ -409,7 +319,7 @@ export class FileController { @AddUser() auth: AuthHeader, ): Promise { logger.debug(`cancelUpload ${JSON.stringify(dto)}`); - await this.fileService.cancelUpload( + await this.fileLifecycleService.cancelUpload( dto.uuids, dto.missionUuid, auth.user.uuid, @@ -417,7 +327,7 @@ export class FileController { return { success: true }; } - @Post('deleteMultiple') + @Delete() @CanDeleteMission() @ApiOkResponse({ description: 'Delete Files Response', @@ -428,7 +338,7 @@ export class FileController { uuids: string[], @BodyUUID('missionUUID', 'Mission UUID') missionUUID: string, ): Promise { - await this.fileService.deleteMultiple(uuids, missionUUID); + await this.fileLifecycleService.deleteMultiple(uuids, missionUUID); return { success: true }; } @@ -441,28 +351,28 @@ export class FileController { async exists( @QueryUUID('uuid', 'FileUUID searched') uuid: string, ): Promise { - return this.fileService.exists(uuid); + return this.fileQueryService.exists(uuid); } @Post('resetS3Tags') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Resetting S3 tags completed', }) async resetS3Tags(): Promise { logger.debug('Resetting S3 tags'); - await this.fileService.renameTags(); + await this.fileStorageService.renameTags(); logger.debug('Resetting S3 tags done'); } @Post('recomputeFileSizes') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Recomputing file sizes completed', }) async recomputeFileSizes(): Promise { logger.debug('Recomputing file sizes'); - await this.fileService.recomputeFileSizes(); + await this.fileStorageService.recomputeFileSizes(); logger.debug('Recomputing file sizes done'); } @@ -475,18 +385,18 @@ export class FileController { async getEvents( @ParameterUID('uuid') uuid: string, ): Promise { - return this.fileService.getFileEvents(uuid); + return this.fileQueryService.getFileEvents(uuid); } @Post('reextractTopics') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Reextracting topics completed', type: ReextractTopicsResponseDto, }) async reextractTopics(): Promise { logger.debug('Triggering manual topic extraction for missing files'); - const count = await this.fileService.reextractMissingTopics(); + const count = await this.fileLifecycleService.reextractMissingTopics(); return { count }; } @@ -509,7 +419,9 @@ export class FileController { @Post('import/drive') @CanCreateInMissionByBody() - @OutputDto(DriveImportResponseDto) + @ApiCreatedResponse({ + type: DriveImportResponseDto, + }) async importFromDrive( @Body() body: DriveCreate, @AddUser() authHeader: AuthHeader, @@ -522,7 +434,7 @@ export class FileController { @Post('upload/confirm') @LoggedIn() - @ApiOkResponse({ + @ApiCreatedResponse({ type: ConfirmUploadDto, }) async confirmUpload( @@ -554,7 +466,7 @@ export class FileController { @Post('maintenance/recalculate-hashes') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Recalculating hashes completed', type: RecalculateHashesResponseDto, }) @@ -618,7 +530,7 @@ export class FileController { @Post('queue/:uuid/cancel') @CanDeleteMission() - @ApiOkResponse({ + @ApiCreatedResponse({ type: CancelProcessingResponseDto, }) async cancelProcessing( @@ -630,7 +542,7 @@ export class FileController { @Post('queue/:uuid/stop') @CanDeleteMission() - @ApiOkResponse({ + @ApiCreatedResponse({ type: StopJobResponseDto, }) async stopJob( diff --git a/backend/src/endpoints/file/file.module.ts b/backend/src/endpoints/file/file.module.ts index 337f188b5..2cbf9dcd9 100644 --- a/backend/src/endpoints/file/file.module.ts +++ b/backend/src/endpoints/file/file.module.ts @@ -1,7 +1,9 @@ import { FileGuardService } from '@/services/file-guard.service'; -import { FileService } from '@/services/file.service'; +import { FileLifecycleService } from '@/services/file-lifecycle.service'; +import { FileQueryService } from '@/services/file-query.service'; +import { FileStorageService } from '@/services/file-storage.service'; +import { MetadataService } from '@/services/metadata.service'; import { MissionService } from '@/services/mission.service'; -import { TagService } from '@/services/tag.service'; import { TopicService } from '@/services/topic.service'; import { AccessGroupEntity } from '@kleinkram/backend-common'; import { AccountEntity } from '@kleinkram/backend-common/entities/auth/account.entity'; @@ -43,14 +45,16 @@ import { FileController } from './file.controller'; TriggerModule, ], providers: [ - FileService, + FileQueryService, + FileStorageService, + FileLifecycleService, TopicService, MissionService, FileGuardService, - TagService, + MetadataService, ], controllers: [FileController], - exports: [FileService], + exports: [FileQueryService, FileStorageService, FileLifecycleService], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class FileModule {} diff --git a/backend/src/endpoints/metadata/metadata-type.controller.ts b/backend/src/endpoints/metadata/metadata-type.controller.ts new file mode 100644 index 000000000..a68f624d6 --- /dev/null +++ b/backend/src/endpoints/metadata/metadata-type.controller.ts @@ -0,0 +1,53 @@ +import { ApiCreatedResponse, ApiOkResponse } from '@/decorators'; +import { MetadataService } from '@/services/metadata.service'; +import { + CreateTagTypeDto, + FilteredMetadataTypesQueryDto, + PaginatedQueryDto, + TagTypeDto, + TagTypesDto, +} from '@kleinkram/api-dto'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { CanCreate, LoggedIn } from '../auth/roles.decorator'; + +@Controller('metadata-types') +export class MetadataTypeController { + constructor(private readonly metadataService: MetadataService) {} + + @Post() + @CanCreate() + @ApiCreatedResponse({ + description: 'Returns the created TagType', + type: TagTypeDto, + }) + async createTagType(@Body() body: CreateTagTypeDto): Promise { + return await this.metadataService.create(body.name, body.type); + } + + @Get() + @LoggedIn() + @ApiOkResponse({ + description: 'Returns all TagTypes', + type: TagTypesDto, + }) + async getAll(@Query() query: PaginatedQueryDto): Promise { + return this.metadataService.getAll(query.skip, query.take); + } + + @Get('filtered') + @LoggedIn() + @ApiOkResponse({ + description: 'Returns all TagTypes', + type: TagTypesDto, + }) + async getFiltered( + @Query() query: FilteredMetadataTypesQueryDto, + ): Promise { + return this.metadataService.getFiltered( + query.name, + query.type, + query.skip, + query.take, + ); + } +} diff --git a/backend/src/endpoints/metadata/metadata.controller.ts b/backend/src/endpoints/metadata/metadata.controller.ts new file mode 100644 index 000000000..64e6bbf59 --- /dev/null +++ b/backend/src/endpoints/metadata/metadata.controller.ts @@ -0,0 +1,20 @@ +import { ApiOkResponse } from '@/decorators'; +import { MetadataService } from '@/services/metadata.service'; +import { DeleteTagDto } from '@kleinkram/api-dto'; +import { Controller, Delete } from '@nestjs/common'; +import { ParameterUuid as ParameterUID } from '../../validation/parameter-decorators'; +import { CanDeleteTag } from '../auth/roles.decorator'; + +@Controller('metadata') +export class MetadataController { + constructor(private readonly metadataService: MetadataService) {} + + @Delete(':uuid') + @CanDeleteTag() + @ApiOkResponse({ + type: DeleteTagDto, + }) + async deleteTag(@ParameterUID('uuid') uuid: string): Promise { + return this.metadataService.deleteTag(uuid); + } +} diff --git a/backend/src/endpoints/tag/tag.module.ts b/backend/src/endpoints/metadata/metadata.module.ts similarity index 73% rename from backend/src/endpoints/tag/tag.module.ts rename to backend/src/endpoints/metadata/metadata.module.ts index 877f4ed29..2684248fd 100644 --- a/backend/src/endpoints/tag/tag.module.ts +++ b/backend/src/endpoints/metadata/metadata.module.ts @@ -1,4 +1,4 @@ -import { TagService } from '@/services/tag.service'; +import { MetadataService } from '@/services/metadata.service'; import { AccessGroupEntity, ApiKeyEntity } from '@kleinkram/backend-common'; import { AccountEntity } from '@kleinkram/backend-common/entities/auth/account.entity'; import { MetadataEntity } from '@kleinkram/backend-common/entities/metadata/metadata.entity'; @@ -7,7 +7,8 @@ import { ProjectEntity } from '@kleinkram/backend-common/entities/project/projec import { TagTypeEntity } from '@kleinkram/backend-common/entities/tagType/tag-type.entity'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { TagController } from './tag.controller'; +import { MetadataTypeController } from './metadata-type.controller'; +import { MetadataController } from './metadata.controller'; @Module({ imports: [ @@ -21,9 +22,9 @@ import { TagController } from './tag.controller'; ApiKeyEntity, ]), ], - providers: [TagService], - controllers: [TagController], - exports: [TagService], + providers: [MetadataService], + controllers: [MetadataTypeController, MetadataController], + exports: [MetadataService], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class TagModule {} +export class MetadataModule {} diff --git a/backend/src/endpoints/mission/mission.controller.ts b/backend/src/endpoints/mission/mission.controller.ts index 5d244b368..131af83ee 100644 --- a/backend/src/endpoints/mission/mission.controller.ts +++ b/backend/src/endpoints/mission/mission.controller.ts @@ -1,24 +1,33 @@ -import { ApiOkResponse, OutputDto } from '@/decorators'; +import { ApiCreatedResponse, ApiOkResponse } from '@/decorators'; +import { missionEntityToFlatDto } from '@/serialization'; +import { MetadataService } from '@/services/metadata.service'; import { MissionService } from '@/services/mission.service'; +import { QueryUUID } from '@/validation/query-decorators'; import { - QueryOptionalString, - QuerySkip, - QuerySortBy, - QuerySortDirection, - QueryTake, - QueryUUID, -} from '@/validation/query-decorators'; -import { + AddTagsDto, + AddTagsRequestDto, CreateMission, FlatMissionDto, MinimumMissionsDto, + MissionDownloadEntryDto, + MissionQueryDto, MissionsDto, MissionWithFilesDto, + SuccessResponseDto, + UpdateMissionNameDto, } from '@kleinkram/api-dto'; -import { BodyUUID, MISSION_NAME_REGEX } from '@kleinkram/validation'; -import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ParameterUuid as ParameterUID } from '../../validation/parameter-decorators'; import { + CanAddTag, CanCreateInProjectByBody, CanDeleteMission, CanMoveMission, @@ -27,17 +36,18 @@ import { UserOnly, } from '../auth/roles.decorator'; -import { MissionQueryDto } from '@kleinkram/api-dto'; - import { AddUser, AuthHeader } from '../auth/parameter-decorator'; -@Controller(['mission', 'missions']) // TODO: migrate to 'missions' +@Controller('missions') export class MissionController { - constructor(private readonly missionService: MissionService) {} + constructor( + private readonly missionService: MissionService, + private readonly metadataService: MetadataService, + ) {} - @Post('create') + @Post() @CanCreateInProjectByBody() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Returns the created mission', type: FlatMissionDto, }) @@ -48,19 +58,21 @@ export class MissionController { return this.missionService.create(createMission, user); } - @Post('updateName') + @Patch(':uuid/name') @CanWriteMissionByBody() - @OutputDto(null) // TODO: type API response + @ApiOkResponse({ + description: 'Returns the updated mission', + type: FlatMissionDto, + }) async updateMissionName( - @BodyUUID('missionUUID', 'Mission UUID') missionUUID: string, - @Body('name') name: string, - ) { - // validate name - if (!MISSION_NAME_REGEX.test(name)) { - throw new Error('Invalid name'); - } - - return this.missionService.updateName(missionUUID, name); + @ParameterUID('uuid') missionUUID: string, + @Body() body: UpdateMissionNameDto, + ): Promise { + const updatedMission = await this.missionService.updateName( + missionUUID, + body.name, + ); + return missionEntityToFlatDto(updatedMission); } @Get() @@ -72,120 +84,72 @@ export class MissionController { async getMany( @Query() query: MissionQueryDto, @AddUser() user: AuthHeader, - ): Promise { - return await this.missionService.findMany( - query.projectUuids ?? [], - query.projectPatterns ?? [], - query.missionUuids ?? [], - query.missionPatterns ?? [], - query.metadata ?? {}, - query.sortBy, - query.sortOrder, - query.skip, - query.take, - user.user.uuid, - ); + ): Promise { + return await this.missionService.findMany(query, user.user.uuid); } - @Get('filteredMinimal') - @UserOnly() - @ApiOkResponse({ - description: 'Returns all missions filtered by project', - type: MinimumMissionsDto, - }) - async filteredMissionsMinimal( - @QueryUUID('uuid', 'Project UUID') uuid: string, - @QueryOptionalString('search', 'Search in mission name') search: string, - @QuerySortDirection('sortDirection') sortDirection: 'ASC' | 'DESC', - @QuerySortBy('sortBy') sortBy: string, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - @AddUser() user: AuthHeader, - ): Promise { - return this.missionService.findMissionByProjectMinimal( - user.user.uuid, - uuid, - // TODO: fix the following - Number.parseInt(String(skip)), - Number.parseInt(String(take)), - search, - sortDirection, - sortBy, - ); - } - - @Get('filtered') - @UserOnly() - @ApiOkResponse({ - description: 'Returns all missions filtered by project', - type: MissionsDto, - }) - async filteredMissions( - @QueryUUID('uuid', 'Project UUID') uuid: string, - @QueryOptionalString('search', 'Search in mission name') search: string, - @QuerySortDirection('sortDirection') sortDirection: 'ASC' | 'DESC', - @QuerySortBy('sortBy') sortBy: string, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - @AddUser() user: AuthHeader, - ): Promise { - return this.missionService.findMissionByProject( - user.user, - uuid, - // TODO: cleanup by using a dto for the query params - // this automatically validates the query params - // and converts them to the correct types - Number.parseInt(String(skip)), - Number.parseInt(String(take)), - search, - sortDirection, - sortBy, - ); - } - - @Get('one') + @Get(':uuid') @CanReadMission() @ApiOkResponse({ description: 'Returns the mission', type: MissionWithFilesDto, }) async getMissionById( - @QueryUUID('uuid', 'Mission UUID') uuid: string, + @ParameterUID('uuid') uuid: string, ): Promise { return this.missionService.findOne(uuid); } - @Get('download') + @Get(':uuid/download') @CanReadMission() - @OutputDto(null) // TODO: type API response - async downloadWithToken(@QueryUUID('uuid', 'Mission UUID') uuid: string) { + @ApiOkResponse({ + description: 'Download links for the mission files', + type: [MissionDownloadEntryDto], + }) + async downloadWithToken( + @ParameterUID('uuid') uuid: string, + ): Promise { return this.missionService.download(uuid); } - @Post('move') + @Post(':uuid/move') @CanMoveMission() - @OutputDto(null) // TODO: type API response + @ApiCreatedResponse({ + description: 'Mission moved successfully', + type: SuccessResponseDto, + }) async moveMission( - @QueryUUID('missionUUID', 'Mission UUID') missionUUID: string, + @ParameterUID('uuid') missionUUID: string, @QueryUUID('projectUUID', 'Project UUID') projectUUID: string, - ): Promise { - return this.missionService.moveMission(missionUUID, projectUUID); + ): Promise { + await this.missionService.moveMission(missionUUID, projectUUID); + return { success: true }; } @Delete(':uuid') @CanDeleteMission() - @OutputDto(null) // TODO: type API response - async deleteMission(@ParameterUID('uuid') uuid: string): Promise { - return this.missionService.deleteMission(uuid); + @ApiOkResponse({ + description: 'Mission deleted', + type: SuccessResponseDto, + }) + async deleteMission( + @ParameterUID('uuid') uuid: string, + ): Promise { + await this.missionService.deleteMission(uuid); + return { success: true }; } - @Post('tags') - @CanWriteMissionByBody() - @OutputDto(null) // TODO: type API response - async updateMissionTags( - @BodyUUID('missionUUID', 'Mission UUID') missionUUID: string, - @Body('tags') tags: Record, - ): Promise { - await this.missionService.updateTags(missionUUID, tags); + @Post(':uuid/metadata') + @CanAddTag() + @ApiCreatedResponse({ + description: 'Metadata added to mission', + type: AddTagsDto, + }) + async addTags( + @ParameterUID('uuid') uuid: string, + @Body() body: AddTagsRequestDto, + ): Promise { + const metadata = body.metadata ?? body.tags ?? {}; + return this.metadataService.addTags(uuid, metadata); } } diff --git a/backend/src/endpoints/mission/mission.module.ts b/backend/src/endpoints/mission/mission.module.ts index 2319c7510..6de2efce3 100644 --- a/backend/src/endpoints/mission/mission.module.ts +++ b/backend/src/endpoints/mission/mission.module.ts @@ -1,5 +1,5 @@ +import { MetadataService } from '@/services/metadata.service'; import { MissionService } from '@/services/mission.service'; -import { TagService } from '@/services/tag.service'; import { UserService } from '@/services/user.service'; import { AccessGroupEntity } from '@kleinkram/backend-common'; import { AccountEntity } from '@kleinkram/backend-common/entities/auth/account.entity'; @@ -24,7 +24,7 @@ import { MissionController } from './mission.controller'; ]), StorageModule, ], - providers: [MissionService, UserService, TagService], + providers: [MissionService, UserService, MetadataService], controllers: [MissionController], exports: [MissionService], }) diff --git a/backend/src/endpoints/project/project.controller.ts b/backend/src/endpoints/project/project.controller.ts index e14ea8f24..2b7f3c6eb 100644 --- a/backend/src/endpoints/project/project.controller.ts +++ b/backend/src/endpoints/project/project.controller.ts @@ -1,11 +1,18 @@ -import { ApiOkResponse, ApiResponse, OutputDto } from '@/decorators'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiResponse, + OutputDto, +} from '@/decorators'; import { projectEntityToDto } from '@/serialization'; -import { AccessService } from '@/services/access.service'; +import { AccessModificationService } from '@/services/access-modification.service'; +import { AccessQueryService } from '@/services/access-query.service'; import { ProjectService } from '@/services/project.service'; import { ParameterUuid as ParameterUID } from '@/validation/parameter-decorators'; -import { QueryTake, QueryUUID } from '@/validation/query-decorators'; +import { QueryTake } from '@/validation/query-decorators'; import { - AddTagTypeDto, + AddMetadataTypeDto, + AddMetadataTypeQueryDto, AddUserToProjectDto, CreateProject, DefaultRights, @@ -18,9 +25,9 @@ import { ProjectWithRequiredTagsDto, RemoveTagTypeDto, ResentProjectsDto, - UpdateTagTypesDto, + UpdateMetadataTypesBodyDto, + UpdateMetadataTypesDto, } from '@kleinkram/api-dto'; -import { BodyUUIDArray } from '@kleinkram/validation'; import { Body, Controller, @@ -46,12 +53,13 @@ import { export class ProjectController { constructor( private readonly projectService: ProjectService, - private readonly accessService: AccessService, + private readonly accessQueryService: AccessQueryService, + private readonly accessModificationService: AccessModificationService, ) {} @Post() @CanCreate() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Returns the created project', type: ProjectDto, }) @@ -62,7 +70,51 @@ export class ProjectController { return this.projectService.create(dto, user); } - // dont match filtered, recent, and getDefaultRights, TODO: fix this at some point + @Get('recent') + @UserOnly() + @ApiOperation({ + summary: 'Get recent projects', + description: + 'Get the most recent projects the current user has access to', + }) + @ApiOkResponse({ + description: 'Returns the most recent projects', + type: ResentProjectsDto, + }) + async getRecentProjects( + @QueryTake('take') take: number, + @AddUser() user: AuthHeader, + ): Promise { + const projects = await this.projectService.getRecentProjects( + take, + user.user, + ); + + return { + data: projects, + count: projects.length, + skip: 0, + take: projects.length, + }; + } + + @Get('default-rights') + @LoggedIn() + @ApiOperation({ + summary: 'Get default rights', + description: `Get the default rights for a project, the default rights + are the rights that should be assigned to a new project upon creation`, + }) + @ApiOkResponse({ + description: 'Returns the default rights for a project', + type: DefaultRights, + }) + async getDefaultRights( + @AddUser() user: AuthHeader, + ): Promise { + return this.projectService.getDefaultRights(user); + } + @Get(':uuid') @CanReadProject() @ApiOkResponse({ @@ -130,8 +182,7 @@ export class ProjectController { summary: 'Add User to Project', description: 'Adds a user to a project with the given rights.', }) - @ApiResponse({ - status: 200, + @ApiCreatedResponse({ type: ProjectDto, description: 'The Project the user was added to.', }) @@ -143,30 +194,32 @@ export class ProjectController { @Body() body: AddUserToProjectDto, @AddUser() requestUser: AuthHeader, ): Promise { - const projectEntity = await this.accessService.addUserToProject( - uuid, - body.userUuid, - body.rights, - requestUser, - ); + const projectEntity = + await this.accessModificationService.addUserToProject( + uuid, + body.userUuid, + body.rights, + requestUser, + ); return projectEntityToDto(projectEntity); } - @Post(':uuid/addTagType') + @Post(':uuid/metadata-types') @CanWriteProject() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Empty response', - type: AddTagTypeDto, + type: AddMetadataTypeDto, }) async addTagType( @ParameterUID('uuid') uuid: string, - @QueryUUID('tagTypeUUID', 'TagType UUID') tagTypeUUID: string, - ): Promise { - await this.projectService.addTagType(uuid, tagTypeUUID); + @Query() query: AddMetadataTypeQueryDto, + ): Promise { + const typeUuid = query.metadataTypeUUID ?? query.tagTypeUUID ?? ''; + await this.projectService.addTagType(uuid, typeUuid); return {}; } - @Post(':uuid/removeTagType') + @Delete(':uuid/metadata-types/:typeUuid') @CanWriteProject() @ApiOkResponse({ type: RemoveTagTypeDto, @@ -174,24 +227,24 @@ export class ProjectController { }) async removeTagType( @ParameterUID('uuid') uuid: string, - @QueryUUID('tagTypeUUID', 'TagType UUID') tagTypeUUID: string, + @ParameterUID('typeUuid') typeUuid: string, ): Promise { - await this.projectService.removeTagType(uuid, tagTypeUUID); + await this.projectService.removeTagType(uuid, typeUuid); return {}; } - @Post(':uuid/updateTagTypes') + @Put(':uuid/metadata-types') @CanWriteProject() @ApiOkResponse({ description: 'Empty response', - type: UpdateTagTypesDto, + type: UpdateMetadataTypesDto, }) async updateTagTypes( @ParameterUID('uuid') uuid: string, - @BodyUUIDArray('tagTypeUUIDs', 'List of Tagtype UUID to set') - tagTypeUUIDs: string[], - ): Promise { - await this.projectService.updateTagTypes(uuid, tagTypeUUIDs); + @Body() body: UpdateMetadataTypesBodyDto, + ): Promise { + const uuids = body.metadataTypeUUIDs ?? body.tagTypeUUIDs ?? []; + await this.projectService.updateTagTypes(uuid, uuids); return { success: true, }; @@ -206,12 +259,12 @@ export class ProjectController { async getProjectAccess( @ParameterUID('uuid') uuid: string, ): Promise { - return this.accessService.getProjectAccesses(uuid); + return this.accessQueryService.getProjectAccesses(uuid); } @Post(':uuid/access') @CanWriteProject() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Returns the project access', type: ProjectAccessListDto, }) @@ -221,60 +274,10 @@ export class ProjectController { body: ProjectAccessDto[], @AddUser() auth: AuthHeader, ): Promise { - return this.accessService.updateProjectAccess(uuid, body, auth); - } -} - -// TODO: this controller should get removed at some point, -// filtered and recent will effectively be replaced by `GET /projects` -// for the getDefaultRights endpoint we should make a separate controller that -// does all the access control stuff -@Controller('oldProject') -export class OldProjectController { - constructor(private readonly projectService: ProjectService) {} - - @Get('recent') - @UserOnly() - @ApiOperation({ - summary: 'Get recent projects', - description: - 'Get the most recent projects the current user has access to', - }) - @ApiOkResponse({ - description: 'Returns the most recent projects', - type: ResentProjectsDto, - }) - async getRecentProjects( - @QueryTake('take') take: number, - @AddUser() user: AuthHeader, - ): Promise { - const projects = await this.projectService.getRecentProjects( - take, - user.user, + return this.accessModificationService.updateProjectAccess( + uuid, + body, + auth, ); - - return { - data: projects, - count: projects.length, - skip: 0, - take: projects.length, - }; - } - - @Get('getDefaultRights') - @LoggedIn() - @ApiOperation({ - summary: 'Get default rights', - description: `Get the default rights for a project, the default rights - are the rights that should be assigned to a new project upon creation`, - }) - @ApiOkResponse({ - description: 'Returns the default rights for a project', - type: DefaultRights, - }) - async getDefaultRights( - @AddUser() user: AuthHeader, - ): Promise { - return this.projectService.getDefaultRights(user); } } diff --git a/backend/src/endpoints/project/project.module.ts b/backend/src/endpoints/project/project.module.ts index 51e2f6f76..0e548b340 100644 --- a/backend/src/endpoints/project/project.module.ts +++ b/backend/src/endpoints/project/project.module.ts @@ -1,7 +1,5 @@ -import { AccessService } from '@/services/access.service'; import { ProjectService } from '@/services/project.service'; import { - AccessGroupAuditService, AccessGroupEntity, AccessGroupEventEntity, GroupMembershipEntity, @@ -13,7 +11,8 @@ import { ProjectAccessEntity } from '@kleinkram/backend-common/entities/auth/pro import { TagTypeEntity } from '@kleinkram/backend-common/entities/tagType/tag-type.entity'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { OldProjectController, ProjectController } from './project.controller'; +import { AccessModule } from '../access/access.module'; +import { ProjectController } from './project.controller'; @Module({ imports: [ @@ -27,10 +26,11 @@ import { OldProjectController, ProjectController } from './project.controller'; UserEntity, GroupMembershipEntity, ]), + AccessModule, ], - providers: [ProjectService, AccessService, AccessGroupAuditService], + providers: [ProjectService], exports: [ProjectService], - controllers: [ProjectController, OldProjectController], + controllers: [ProjectController], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class ProjectModule {} diff --git a/backend/src/endpoints/tag/tag.controller.ts b/backend/src/endpoints/tag/tag.controller.ts deleted file mode 100644 index 757f32f68..000000000 --- a/backend/src/endpoints/tag/tag.controller.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ApiOkResponse } from '@/decorators'; -import { TagService } from '@/services/tag.service'; -import { - QueryOptionalString, - QuerySkip, - QueryTake, -} from '@/validation/query-decorators'; -import { - AddTagsDto, - CreateTagTypeDto, - DeleteTagDto, - TagTypeDto, - TagTypesDto, -} from '@kleinkram/api-dto'; -import { DataType } from '@kleinkram/shared'; -import { BodyNotNull, BodyUUID } from '@kleinkram/validation'; -import { Body, Controller, Delete, Get, Post } from '@nestjs/common'; -import { ParameterUuid as ParameterUID } from '../../validation/parameter-decorators'; -import { - CanAddTag, - CanCreate, - CanDeleteTag, - LoggedIn, -} from '../auth/roles.decorator'; - -@Controller('tag') -export class TagController { - constructor(private readonly tagService: TagService) {} - - @Post('create') - @CanCreate() - @ApiOkResponse({ - description: 'Returns the created TagType', - type: TagTypeDto, - }) - async createTagType(@Body() body: CreateTagTypeDto): Promise { - return await this.tagService.create(body.name, body.type); - } - - @Post('addTags') - @CanAddTag() - @ApiOkResponse({ - type: AddTagsDto, - }) - async addTags( - @BodyUUID('uuid', 'Mission UUID') uuid: string, - @BodyNotNull('tags', 'Record Tagtype UUID to Tag value') - tags: Record, - ): Promise { - return this.tagService.addTags(uuid, tags); - } - - @Delete(':uuid') - @CanDeleteTag() - @ApiOkResponse({ - type: DeleteTagDto, - }) - async deleteTag(@ParameterUID('uuid') uuid: string): Promise { - return this.tagService.deleteTag(uuid); - } - - @Get('all') - @LoggedIn() - @ApiOkResponse({ - description: 'Returns all TagTypes', - type: TagTypesDto, - }) - async getAll( - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - ): Promise { - return this.tagService.getAll(skip, take); - } - - @Get('filtered') - @LoggedIn() - @ApiOkResponse({ - description: 'Returns all TagTypes', - type: TagTypesDto, - }) - async getFiltered( - @QueryOptionalString('name', 'Filter by TagType name') name: string, - @QueryOptionalString('type', 'Filter by TagType datatype') - type: DataType, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - ): Promise { - return this.tagService.getFiltered(name, type, skip, take); - } -} diff --git a/backend/src/endpoints/templates/templates.controller.ts b/backend/src/endpoints/templates/templates.controller.ts index c5579f75c..bc380e429 100644 --- a/backend/src/endpoints/templates/templates.controller.ts +++ b/backend/src/endpoints/templates/templates.controller.ts @@ -1,7 +1,9 @@ import { ActionTemplateDto, ActionTemplatesDto, + ActionTemplatesQueryDto, CreateTemplateDto, + PaginatedQueryDto, UpdateTemplateDto, } from '@kleinkram/api-dto'; import { @@ -16,10 +18,9 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ApiOkResponse, OutputDto } from '@/decorators'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '@/decorators'; import { TemplateService } from '@/services/template.service'; import { ParameterUuid } from '@/validation/parameter-decorators'; -import { QuerySkip, QueryTake } from '@/validation/query-decorators'; import { ActionTemplateAvailabilityDto } from '@kleinkram/api-dto'; import { AddUser, AuthHeader } from '../auth/parameter-decorator'; import { CanCreate, LoggedIn } from '../auth/roles.decorator'; @@ -32,7 +33,7 @@ export class TemplatesController { @Post() @CanCreate() @ApiOperation({ summary: 'Create a new action template' }) - @ApiOkResponse({ type: ActionTemplateDto }) + @ApiCreatedResponse({ type: ActionTemplateDto }) async createNewTemplate( @Body() dto: CreateTemplateDto, @AddUser() user: AuthHeader, @@ -43,7 +44,7 @@ export class TemplatesController { @Post(':uuid/versions') @CanCreate() @ApiOperation({ summary: 'Create a new version of an existing template' }) - @ApiOkResponse({ type: ActionTemplateDto }) + @ApiCreatedResponse({ type: ActionTemplateDto }) async createNewTemplateVersion( @Body() dto: UpdateTemplateDto, @AddUser() user: AuthHeader, @@ -59,16 +60,13 @@ export class TemplatesController { type: ActionTemplatesDto, }) async findAllTemplates( - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - @Query('search') search?: string, - @Query('includeArchived') includeArchived?: boolean, + @Query() query: ActionTemplatesQueryDto, ): Promise { return this.templateService.findAll( - skip, - take, - search, - includeArchived, + query.skip, + query.take, + query.search, + query.includeArchived, ); } @@ -89,10 +87,9 @@ export class TemplatesController { @ApiOkResponse({ type: ActionTemplatesDto }) async findTemplateRevisions( @ParameterUuid('uuid') uuid: string, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, + @Query() query: PaginatedQueryDto, ): Promise { - return this.templateService.findRevisions(uuid, skip, take); + return this.templateService.findRevisions(uuid, query.skip, query.take); } @Delete(':uuid') diff --git a/backend/src/endpoints/topic/topic.controller.ts b/backend/src/endpoints/topic/topic.controller.ts index 3670df22e..0aebcc6ec 100644 --- a/backend/src/endpoints/topic/topic.controller.ts +++ b/backend/src/endpoints/topic/topic.controller.ts @@ -1,16 +1,20 @@ import { ApiOkResponse } from '@/decorators'; import { TopicService } from '@/services/topic.service'; -import { QuerySkip, QueryTake } from '@/validation/query-decorators'; -import { TopicNamesDto, TopicsDto, TopicTypesDto } from '@kleinkram/api-dto'; -import { Controller, Get } from '@nestjs/common'; +import { + PaginatedQueryDto, + TopicNamesDto, + TopicsDto, + TopicTypesDto, +} from '@kleinkram/api-dto'; +import { Controller, Get, Query } from '@nestjs/common'; import { AddUser, AuthHeader } from '../auth/parameter-decorator'; import { LoggedIn } from '../auth/roles.decorator'; -@Controller('topic') +@Controller('topics') export class TopicController { constructor(private readonly topicService: TopicService) {} - @Get('all') + @Get() @LoggedIn() @ApiOkResponse({ description: 'Get all topics', @@ -18,10 +22,13 @@ export class TopicController { }) async allTopics( @AddUser() user: AuthHeader, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, + @Query() query: PaginatedQueryDto, ): Promise { - return await this.topicService.findAll(user.user.uuid, skip, take); + return await this.topicService.findAll( + user.user.uuid, + query.skip, + query.take, + ); } @Get('names') diff --git a/backend/src/endpoints/trigger/hook.controller.ts b/backend/src/endpoints/trigger/hook.controller.ts index 8938a83cf..fee7db56b 100644 --- a/backend/src/endpoints/trigger/hook.controller.ts +++ b/backend/src/endpoints/trigger/hook.controller.ts @@ -5,7 +5,7 @@ import { ActionState } from '@kleinkram/shared'; import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; -import { ApiOkResponse, OutputDto } from '../../decorators'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '../../decorators'; import { PublicGuard } from '../auth/guards'; import { ThrottlerLoggerGuard } from './throttler-logger.guard'; @@ -30,8 +30,7 @@ export class HookController { @Post(':uuid') @UseGuards(PublicGuard, ThrottlerLoggerGuard) @Throttle({ default: { limit: 10, ttl: 60_000 } }) - @OutputDto(WebhookTriggerResponseDto) - @ApiOkResponse({ type: WebhookTriggerResponseDto }) + @ApiCreatedResponse({ type: WebhookTriggerResponseDto }) async triggerPost( @ParameterUuid('uuid') uuid: string, @Body() body: Record, diff --git a/backend/src/endpoints/trigger/trigger.controller.ts b/backend/src/endpoints/trigger/trigger.controller.ts index 71267e28a..719a2337f 100644 --- a/backend/src/endpoints/trigger/trigger.controller.ts +++ b/backend/src/endpoints/trigger/trigger.controller.ts @@ -15,7 +15,7 @@ import { Query, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { ApiOkResponse, OutputDto } from '../../decorators'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '../../decorators'; import { AddUser, AuthHeader } from '../auth/parameter-decorator'; import { CanCreateInMissionByBody, @@ -39,7 +39,7 @@ export class TriggerController { @Post() @CanCreateInMissionByBody() - @ApiOkResponse({ type: ActionTriggerDto }) + @ApiCreatedResponse({ type: ActionTriggerDto }) async create( @Body() dto: CreateActionTriggerDto, @AddUser() auth: AuthHeader, diff --git a/backend/src/endpoints/user/user.controller.ts b/backend/src/endpoints/user/user.controller.ts index 799dc1943..4c8fa3c95 100644 --- a/backend/src/endpoints/user/user.controller.ts +++ b/backend/src/endpoints/user/user.controller.ts @@ -1,37 +1,31 @@ -import { ApiOkResponse, OutputDto } from '@/decorators'; +import { ApiCreatedResponse, ApiOkResponse, OutputDto } from '@/decorators'; import { UserService } from '@/services/user.service'; -import { - QuerySkip, - QuerySortBy, - QuerySortDirection, - QueryString, - QueryTake, -} from '@/validation/query-decorators'; import { ApiKeysDto, CurrentAPIUserDto, NoQueryParametersDto, + PaginatedQueryDto, PermissionsDto, ResolveUsersDto, + SortablePaginatedQueryDto, UserDto, UsersDto, } from '@kleinkram/api-dto'; -import { SortOrder } from '@kleinkram/api-dto/types/pagination'; import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { ApiOperation, - ApiOkResponse as SwaggerApiOkResponse, + ApiCreatedResponse as SwaggerApiCreatedResponse, } from '@nestjs/swagger'; import { AddUser, AuthHeader } from '../auth/parameter-decorator'; import { AdminOnly, LoggedIn, UserOnly } from '../auth/roles.decorator'; -@Controller('user') +@Controller('users') export class UserController { constructor(private readonly userService: UserService) {} - @Post('claimAdmin') + @Post('admin/claim') @UserOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Claimed admin', type: CurrentAPIUserDto, }) @@ -39,17 +33,14 @@ export class UserController { return this.userService.claimAdmin(user); } - @Get('all') + @Get() @AdminOnly() @ApiOkResponse({ description: 'All users', type: UsersDto, }) - async allUsers( - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - ): Promise { - return this.userService.findAll(skip, take); + async allUsers(@Query() query: PaginatedQueryDto): Promise { + return this.userService.findAll(query.skip, query.take); } @Get('me') @@ -68,7 +59,7 @@ export class UserController { @Post('promote') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Claimed admin', type: UserDto, }) @@ -78,7 +69,7 @@ export class UserController { @Post('demote') @AdminOnly() - @ApiOkResponse({ + @ApiCreatedResponse({ description: 'Claimed admin', type: UserDto, }) @@ -88,16 +79,16 @@ export class UserController { @Get('search') @LoggedIn() - @OutputDto(null) // TODO: Add type - async search( - @QueryString('search', 'Searchkey on name or email') search: string, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - ): Promise { - return this.userService.search(search, skip, take); + @OutputDto(UsersDto) + async search(@Query() query: PaginatedQueryDto): Promise { + return this.userService.search( + query.search ?? '', + query.skip, + query.take, + ); } - @Get('permissions') + @Get('me/permissions') @LoggedIn() @ApiOkResponse({ type: PermissionsDto, @@ -109,7 +100,7 @@ export class UserController { return this.userService.getUserPermissions(authHeader.user.uuid); } - @Get('api-keys') + @Get('me/api-keys') @LoggedIn() @ApiOperation({ summary: 'Get API key metadata for the current user' }) @ApiOkResponse({ @@ -118,24 +109,14 @@ export class UserController { }) async apiKeys( @AddUser() authHeader: AuthHeader, - @QuerySkip('skip') skip: number, - @QueryTake('take') take: number, - @QuerySortBy('sortBy', 'Sort by field', [ - 'uuid', - 'key_type', - 'rights', - 'deletedAt', - 'createdAt', - ]) - sortBy: string, - @QuerySortDirection('sortOrder') sortOrder: string, + @Query() query: SortablePaginatedQueryDto, ): Promise { return this.userService.getApiKeysForUser( authHeader.user.uuid, - skip, - take, - sortBy, - sortOrder === 'ASC' ? SortOrder.ASC : SortOrder.DESC, + query.skip, + query.take, + query.sortBy ?? 'createdAt', + query.sortOrder, ); } @@ -143,7 +124,7 @@ export class UserController { @LoggedIn() @ApiOperation({ summary: 'Resolve a list of User UUIDs to their names' }) @OutputDto(null) - @SwaggerApiOkResponse({ + @SwaggerApiCreatedResponse({ description: 'Mapping from user UUIDs to their display names', schema: { type: 'object', diff --git a/backend/src/endpoints/worker/worker.controller.ts b/backend/src/endpoints/worker/worker.controller.ts index 4705c4024..6e8987443 100644 --- a/backend/src/endpoints/worker/worker.controller.ts +++ b/backend/src/endpoints/worker/worker.controller.ts @@ -5,11 +5,11 @@ import { Controller, Get } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; import { LoggedIn } from '../auth/roles.decorator'; -@Controller('worker') +@Controller('workers') export class WorkerController { constructor(private readonly workerService: WorkerService) {} - @Get('all') + @Get() @LoggedIn() @ApiOperation({ summary: 'Get all workers', diff --git a/backend/src/serialization/index.ts b/backend/src/serialization/index.ts index 93a886a5e..6693cd2e4 100644 --- a/backend/src/serialization/index.ts +++ b/backend/src/serialization/index.ts @@ -7,6 +7,7 @@ import { MissionEntity } from '@kleinkram/backend-common/entities/mission/missio import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; import { TagTypeEntity } from '@kleinkram/backend-common/entities/tagType/tag-type.entity'; import { TopicEntity } from '@kleinkram/backend-common/entities/topic/topic.entity'; +import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; import { AccessGroupDto, @@ -20,45 +21,50 @@ import { MissionDto, MissionWithCreatorDto, MissionWithFilesDto, + ProjectAccessDto, ProjectDto, - ProjectWithMissionsDto, ProjectWithRequiredTagsDto, TagDto, TagTypeDto, TopicDto, UserDto, } from '@kleinkram/api-dto'; -import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; -import { - AccessGroupRights, - AccessGroupType, - UserRole, -} from '@kleinkram/shared'; +import { plainToInstance } from 'class-transformer'; export const userEntityToDto = ( user: UserEntity, includeEmail = false, ): UserDto => { - return { - uuid: user.uuid, - name: user.name, - avatarUrl: user.avatarUrl ?? null, - email: includeEmail && user.email ? user.email : null, - }; + return plainToInstance(UserDto, user, { + excludeExtraneousValues: true, + groups: includeEmail ? ['includeEmail'] : [], + }); }; export const userEntityToCurrentAPIUserDto = ( user: UserEntity, ): CurrentAPIUserDto => { - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...userEntityToDto(user, true), - role: user.role ?? UserRole.USER, - memberships: - user.memberships?.map((m) => - groupMembershipEntityToDto(m, false, user, true), - ) ?? [], - }; + // If memberships are present, ensure they have the user populated + const memberships = user.memberships?.map((m) => { + if (!m.user) { + const copy = Object.create( + Object.getPrototypeOf(m) as object, + ) as GroupMembershipEntity; + Object.assign(copy, m, { user }); + return copy; + } + return m; + }); + + const userCopy = Object.create( + Object.getPrototypeOf(user) as object, + ) as UserEntity; + Object.assign(userCopy, user, { memberships }); + + return plainToInstance(CurrentAPIUserDto, userCopy, { + excludeExtraneousValues: true, + groups: ['includeEmail', 'includeAccessGroup'], + }); }; export const missionEntityToDto = (mission: MissionEntity): MissionDto => { @@ -66,14 +72,9 @@ export const missionEntityToDto = (mission: MissionEntity): MissionDto => { throw new Error('Mission project is not set'); } - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...missionEntityToMinimumDto(mission), - project: projectEntityToDto(mission.project), - createdAt: mission.createdAt, - tags: mission.tags?.map((element) => tagEntityToDto(element)) ?? [], - updatedAt: mission.updatedAt, - }; + return plainToInstance(MissionDto, mission, { + excludeExtraneousValues: true, + }); }; export const missionEntityToDtoWithCreator = ( @@ -83,22 +84,21 @@ export const missionEntityToDtoWithCreator = ( throw new Error('Mission creator is not set'); } - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...missionEntityToDto(mission), - creator: userEntityToDto(mission.creator), - }; + return plainToInstance(MissionWithCreatorDto, mission, { + excludeExtraneousValues: true, + }); }; export const missionEntityToFlatDto = ( mission: MissionEntity, ): FlatMissionDto => { - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...missionEntityToDtoWithCreator(mission), - filesCount: mission.fileCount ?? 0, - size: mission.size ?? 0, - }; + if (!mission.creator) { + throw new Error('Mission creator is not set'); + } + + return plainToInstance(FlatMissionDto, mission, { + excludeExtraneousValues: true, + }); }; export const missionEntityToDtoWithFiles = ( @@ -109,35 +109,30 @@ export const missionEntityToDtoWithFiles = ( } if (!mission.tags) { + throw new Error('Mission tags are not set'); + } + + if (!mission.creator) { throw new Error('Mission creator is not set'); } - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...(missionEntityToDtoWithCreator(mission) as MissionWithFilesDto), - files: mission.files.map((element) => fileEntityToDto(element)), - tags: mission.tags.map((element) => tagEntityToDto(element)), - }; + return plainToInstance(MissionWithFilesDto, mission, { + excludeExtraneousValues: true, + }); }; export const missionEntityToMinimumDto = ( mission: MissionEntity, ): MinimumMissionDto => { - return { - name: mission.name, - uuid: mission.uuid, - }; + return plainToInstance(MinimumMissionDto, mission, { + excludeExtraneousValues: true, + }); }; export const tagTypeEntityToDto = (tagType: TagTypeEntity): TagTypeDto => { - return { - uuid: tagType.uuid, - createdAt: tagType.createdAt, - updatedAt: tagType.updatedAt, - name: tagType.name, - description: tagType.description ?? '', - datatype: tagType.datatype, - }; + return plainToInstance(TagTypeDto, tagType, { + excludeExtraneousValues: true, + }); }; export const fileEntityToDto = (file: FileEntity): FileDto => { @@ -149,68 +144,30 @@ export const fileEntityToDto = (file: FileEntity): FileDto => { throw new Error('File mission is not set'); } - const relatedUuid = file.parent?.uuid ?? file.derivedFiles?.[0]?.uuid; - - return { - uuid: file.uuid, - state: file.state, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - date: file.date, - filename: file.filename, - type: file.type, - size: file.size ?? 0, - hash: file.hash ?? '', - relatedFileUuid: relatedUuid, - creator: userEntityToDto(file.creator), - mission: missionEntityToDto(file.mission), - categories: - file.categories?.map((c) => ({ - uuid: c.uuid, - name: c.name, - })) ?? [], - }; + return plainToInstance(FileDto, file, { + excludeExtraneousValues: true, + }); }; export const fileEntityToDtoWithTopic = ( file: FileEntity, ): FileWithTopicDto => { - let topics = file.topics ?? []; - - // Fallback: no topics on this file, check derived files - if (topics.length === 0 && file.derivedFiles?.length) { - const derivedWithTopics = file.derivedFiles.find( - (f) => f.topics && f.topics.length > 0, - ); - if (derivedWithTopics) { - topics = derivedWithTopics.topics ?? []; - } + if (!file.creator) { + throw new Error('File creator is not set'); } - // Fallback: child file without topics, check parent - if (topics.length === 0 && file.parent?.topics?.length) { - topics = file.parent.topics; + if (!file.mission) { + throw new Error('File mission is not set'); } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!topics) topics = []; - - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...(fileEntityToDto(file) as FileWithTopicDto), - topics: topics.map((element) => topicEntityToDto(element)), - }; + return plainToInstance(FileWithTopicDto, file, { + excludeExtraneousValues: true, + }); }; export const projectAccessEntityToDto = ( projectAccess: ProjectAccessEntity, -): { - memberCount: number; - type: AccessGroupType; - name: string; - rights: AccessGroupRights; - uuid: string; -} => { +): ProjectAccessDto => { if (projectAccess.accessGroup === undefined) { throw new Error('Access group not found'); } @@ -219,13 +176,9 @@ export const projectAccessEntityToDto = ( throw new Error('Access group has no memberships'); } - return { - memberCount: projectAccess.accessGroup.memberships.length, - type: projectAccess.accessGroup.type, - name: projectAccess.accessGroup.name, - rights: projectAccess.rights, - uuid: projectAccess.accessGroup.uuid, - }; + return plainToInstance(ProjectAccessDto, projectAccess, { + excludeExtraneousValues: true, + }); }; export function groupMembershipEntityToDto( @@ -240,48 +193,43 @@ export function groupMembershipEntityToDto( throw new Error('Member can never be undefined'); } - return { - uuid: groupMembership.uuid, - canEditGroup: groupMembership.canEditGroup, - accessGroup: - includeAccessGroup && groupMembership.accessGroup - ? accessGroupEntityToDto(groupMembership.accessGroup) - : null, - createdAt: groupMembership.createdAt, - updatedAt: groupMembership.updatedAt, - expirationDate: groupMembership.expirationDate ?? null, - user: userEntityToDto(user, includeEmail), - }; + const groups: string[] = []; + if (includeEmail) groups.push('includeEmail'); + if (includeAccessGroup) groups.push('includeAccessGroup'); + + const copy = Object.create( + Object.getPrototypeOf(groupMembership) as object, + ) as GroupMembershipEntity; + Object.assign(copy, groupMembership, { user }); + + return plainToInstance(GroupMembershipDto, copy, { + excludeExtraneousValues: true, + groups, + }); } export const projectEntityToDto = (project: ProjectEntity): ProjectDto => { - return { - uuid: project.uuid, - name: project.name, - autoConvert: project.autoConvert ?? true, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - description: project.description, - }; + return plainToInstance(ProjectDto, project, { + excludeExtraneousValues: true, + }); }; export const projectEntityToDtoWithRequiredTags = ( project: ProjectEntity, missionCount: number, -): ProjectWithMissionsDto => { +): ProjectWithRequiredTagsDto => { if (project.creator === undefined) { throw new Error('Creator can never be undefined'); } - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...(projectEntityToDto(project) as ProjectWithMissionsDto), - creator: userEntityToDto(project.creator), - missionCount: missionCount, - requiredTags: project.requiredTags.map((element) => - tagTypeEntityToDto(element), - ), - }; + const copy = Object.create( + Object.getPrototypeOf(project) as object, + ) as ProjectEntity; + Object.assign(copy, project, { missionCount }); + + return plainToInstance(ProjectWithRequiredTagsDto, copy, { + excludeExtraneousValues: true, + }); }; export const projectEntityToDtoWithMissionCountAndTags = ( @@ -291,27 +239,15 @@ export const projectEntityToDtoWithMissionCountAndTags = ( throw new Error('Creator can never be undefined'); } - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...(projectEntityToDto(project) as ProjectWithRequiredTagsDto), - creator: userEntityToDto(project.creator), - missionCount: project.missionCount ?? 0, - - requiredTags: - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - project.requiredTags?.map((element) => - tagTypeEntityToDto(element), - ) ?? [], - }; + return plainToInstance(ProjectWithRequiredTagsDto, project, { + excludeExtraneousValues: true, + }); }; export const topicEntityToDto = (topic: TopicEntity): TopicDto => { - return { - name: topic.name, - type: topic.type, - nrMessages: topic.nrMessages ?? 0n, - frequency: Number.isNaN(topic.frequency) ? 0 : topic.frequency, - }; + return plainToInstance(TopicDto, topic, { + excludeExtraneousValues: true, + }); }; export const tagEntityToDto = (tag: MetadataEntity): TagDto => { @@ -319,62 +255,23 @@ export const tagEntityToDto = (tag: MetadataEntity): TagDto => { throw new Error('TagType is not set'); } - return { - get valueAsString(): string { - return this.value.toString(); - }, - type: tagTypeEntityToDto(tag.tagType), - value: - tag.value_string ?? - tag.value_number ?? - tag.value_boolean ?? - tag.value_date ?? - tag.value_location ?? - '', - createdAt: tag.createdAt, - datatype: tag.tagType.datatype, - name: tag.tagType.name, - updatedAt: tag.updatedAt, - uuid: tag.uuid, - }; + return plainToInstance(TagDto, tag, { + excludeExtraneousValues: true, + }); }; export function accessGroupEntityToDto( accessGroup: AccessGroupEntity, ): AccessGroupDto { - return { - uuid: accessGroup.uuid, - name: accessGroup.name, - createdAt: accessGroup.createdAt, - updatedAt: accessGroup.updatedAt, - type: accessGroup.type, - hidden: accessGroup.hidden, - creator: accessGroup.creator - ? userEntityToDto(accessGroup.creator) - : null, - memberships: - accessGroup.memberships?.map((m) => - groupMembershipEntityToDto(m, false, undefined, false), - ) ?? [], - projectAccesses: [], - }; + return plainToInstance(AccessGroupDto, accessGroup, { + excludeExtraneousValues: true, + }); } export const apiKeyEntityToMetadataDto = ( apiKey: ApiKeyEntity, ): ApiKeyMetadataDto => { - return { - uuid: apiKey.uuid, - keyType: apiKey.key_type, - rights: apiKey.rights, - expired: !!apiKey.deletedAt, - createdAt: apiKey.createdAt, - updatedAt: apiKey.updatedAt, - missionUuid: apiKey.mission.uuid, - missionName: apiKey.mission.name, - actionUuid: apiKey.action?.uuid, - actionTemplateName: apiKey.action?.template?.name, - actionTemplateVersion: apiKey.action?.template?.version, - projectUuid: apiKey.mission.project?.uuid, - }; + return plainToInstance(ApiKeyMetadataDto, apiKey, { + excludeExtraneousValues: true, + }); }; diff --git a/backend/src/services/access.service.ts b/backend/src/services/access-modification.service.ts similarity index 67% rename from backend/src/services/access.service.ts rename to backend/src/services/access-modification.service.ts index d4236d226..f1681f340 100644 --- a/backend/src/services/access.service.ts +++ b/backend/src/services/access-modification.service.ts @@ -1,19 +1,13 @@ import { AuthHeader } from '@/endpoints/auth/parameter-decorator'; import { groupMembershipEntityToDto, - projectAccessEntityToDto, projectEntityToDto, - userEntityToDto, } from '@/serialization'; import { - AccessGroupAuditLogsDto, - AccessGroupDto, - AccessGroupsDto, GroupMembershipDto, ProjectAccessDto, ProjectAccessListDto, ProjectDto, - ProjectWithAccessRightsDto, } from '@kleinkram/api-dto'; import { AccessGroupAuditService, @@ -24,29 +18,18 @@ import { ProjectAccessEntity } from '@kleinkram/backend-common/entities/auth/pro import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; import { - AccessGroupConfig, AccessGroupEventType, AccessGroupRights, AccessGroupType, - UserRole, } from '@kleinkram/shared'; import { ConflictException, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; -import { - EntityManager, - FindOptionsWhere, - ILike, - In, - MoreThanOrEqual, - Not, - Repository, -} from 'typeorm'; +import { EntityManager, In, MoreThanOrEqual, Not, Repository } from 'typeorm'; import logger from '../logger'; -import { UserService } from './user.service'; +import { AccessQueryService } from './access-query.service'; @Injectable() -export class AccessService { +export class AccessModificationService { constructor( @InjectRepository(UserEntity) private userRepository: Repository, @@ -59,87 +42,10 @@ export class AccessService { @InjectRepository(ProjectAccessEntity) private projectAccessRepository: Repository, private readonly entityManager: EntityManager, - private readonly configService: ConfigService, private readonly accessGroupAuditService: AccessGroupAuditService, - private readonly userService: UserService, + private readonly accessQueryService: AccessQueryService, ) {} - async getAccessGroup( - uuid: string, - userUuid: string, - ): Promise { - // check if the user can edit the access group (memberships[].canEditGroup) - const includeEmail = - (await this.groupMembershipRepository - .createQueryBuilder('groupMembership') - .leftJoinAndSelect('groupMembership.user', 'user') - .leftJoinAndSelect('groupMembership.accessGroup', 'accessGroup') - .where('accessGroup.uuid = :uuid', { uuid }) - .andWhere('groupMembership.user.uuid = :userUuid', { - userUuid, - }) - .andWhere('groupMembership.canEditGroup = true') - .getCount()) > 0; - - let accessGroupQuery = this.accessGroupRepository - .createQueryBuilder('accessGroup') - .withDeleted() - .leftJoinAndSelect('accessGroup.memberships', 'memberships') - .leftJoinAndSelect('memberships.user', 'user') - .leftJoinAndSelect( - 'accessGroup.project_accesses', - 'project_accesses', - ) - .leftJoinAndSelect('project_accesses.project', 'project') - .leftJoinAndSelect('accessGroup.creator', 'creator') - .where('accessGroup.uuid = :uuid', { uuid }); - - // we need to explicitly select the email field - if (includeEmail) - accessGroupQuery = accessGroupQuery.addSelect('user.email'); - - const rawAccessGroup = await accessGroupQuery.getOneOrFail(); - - return { - createdAt: rawAccessGroup.createdAt, - creator: rawAccessGroup.creator - ? userEntityToDto(rawAccessGroup.creator) - : null, - hidden: rawAccessGroup.hidden, - memberships: - rawAccessGroup.memberships?.map((membership) => - groupMembershipEntityToDto(membership, includeEmail), - ) ?? [], - name: rawAccessGroup.name, - type: rawAccessGroup.type, - updatedAt: rawAccessGroup.updatedAt, - uuid: rawAccessGroup.uuid, - projectAccesses: - rawAccessGroup.project_accesses?.map( - (value) => - ({ - createdAt: value.project?.createdAt, - description: value.project?.description, - updatedAt: value.project?.updatedAt, - name: value.project?.name, - uuid: value.project?.uuid, - rights: value.rights, - autoConvert: value.project?.autoConvert ?? false, - }) as ProjectWithAccessRightsDto, - ) ?? [], - emailPattern: - rawAccessGroup.type === AccessGroupType.AFFILIATION - ? this.configService - .getOrThrow('accessConfig') - .emails.find((emailConfig) => - emailConfig.access_groups.includes( - rawAccessGroup.uuid, - ), - )?.email - : undefined, - }; - } - async createAccessGroup( name: string, auth: AuthHeader, @@ -178,36 +84,6 @@ export class AccessService { return savedGroup; } - async hasProjectRights( - projectUUID: string, - auth: AuthHeader, - rights: AccessGroupRights = AccessGroupRights.WRITE, - ): Promise { - const dbuser = await this.userRepository.findOneOrFail({ - where: { uuid: auth.user.uuid }, - }); - if (dbuser.role === UserRole.ADMIN) { - return true; - } - - return this.projectRepository - .createQueryBuilder('project') - .leftJoin( - 'project_access_view_entity', - 'projectAccesses', - 'projectAccesses.projectuuid = project.uuid', - ) - .where('project.uuid = :uuid', { uuid: projectUUID }) - .andWhere('projectAccesses.rights >= :rights', { - rights: rights, - }) - .andWhere('projectAccesses.useruuid = :user_uuid', { - // eslint-disable-next-line @typescript-eslint/naming-convention - user_uuid: auth.user.uuid, - }) - .getExists(); - } - async addUserToProject( projectUUID: string, userUUID: string, @@ -234,7 +110,7 @@ export class AccessService { if (personalAccessGroup === undefined) throw new Error('User has no personal access group'); - const canUpdate = await this.hasProjectRights( + const canUpdate = await this.accessQueryService.hasProjectRights( projectUUID, auth, rights, @@ -434,76 +310,6 @@ export class AccessService { return result; } - async searchAccessGroup( - search: string, - type: AccessGroupType | undefined, - skip: number, - take: number, - ): Promise { - // we only list the access groups that are not hidden - const where: FindOptionsWhere = { - hidden: false, - }; - - if (type !== undefined) { - where.type = type; - } - - if (search !== '') { - where.name = ILike(`%${search}%`); - } - - const [accessGroups, count] = - await this.accessGroupRepository.findAndCount({ - where, - skip, - take, - relations: [ - 'memberships', - 'memberships.user', - 'project_accesses', - 'project_accesses.project', - 'creator', - ], - }); - - logger.debug(`Search access group with name containing '${search}'`); - logger.debug(`Found ${count.toString()} access groups`); - - const data: AccessGroupDto[] = accessGroups.map( - (accessGroup: AccessGroupEntity): AccessGroupDto => { - return { - creator: accessGroup.creator - ? userEntityToDto(accessGroup.creator) - : null, - memberships: - accessGroup.memberships?.map((membership) => - groupMembershipEntityToDto(membership), - ) ?? [], - createdAt: accessGroup.createdAt, - updatedAt: accessGroup.updatedAt, - uuid: accessGroup.uuid, - name: accessGroup.name, - type: accessGroup.type, - hidden: accessGroup.hidden, - projectAccesses: [], - emailPattern: - accessGroup.type === AccessGroupType.AFFILIATION - ? this.configService - .getOrThrow('accessConfig') - .emails.find((emailConfig) => - emailConfig.access_groups.includes( - accessGroup.uuid, - ), - )?.email - : undefined, - }; - }, - ); - - return { data, count, skip, take }; - } - async addAccessGroupToProject( projectUUID: string, accessGroupUUID: string, @@ -520,7 +326,7 @@ export class AccessService { }); if (rights === AccessGroupRights.DELETE) { - const canDelete = await this.hasProjectRights( + const canDelete = await this.accessQueryService.hasProjectRights( projectUUID, auth, AccessGroupRights.DELETE, @@ -598,7 +404,7 @@ export class AccessService { accessGroupUUID: string, auth: AuthHeader, ) { - const canDelete = await this.hasProjectRights( + const canDelete = await this.accessQueryService.hasProjectRights( projectUUID, auth, AccessGroupRights.DELETE, @@ -641,29 +447,6 @@ export class AccessService { return; } - async getProjectAccesses( - projectUUID: string, - ): Promise { - const [access, count] = await this.projectAccessRepository.findAndCount( - { - where: { project: { uuid: projectUUID } }, - order: { accessGroup: { name: 'ASC' } }, - relations: [ - 'project', - 'accessGroup', - 'accessGroup.memberships', - ], - }, - ); - - return { - data: access.map((element) => projectAccessEntityToDto(element)), - count, - take: count, - skip: 0, - }; - } - private async uncheckedProjectAccessTransactionalUpdate( transaction: EntityManager, projectUuid: string, @@ -691,22 +474,6 @@ export class AccessService { await Promise.all(accessUpdates); } - /** - * PRE-CONDITIONS for updating project access rights: - * - * - the current user must have at least write rights - * - the current user must have at least the same rights - * as the highest rights he has modified - * - * @param transaction - * @param projectUuid - * @param newProjectAccess - * @param userId - * @private - * - * @throws ConflictException if the pre-conditions are not met - * - */ private async checkProjectAccessModificationPreConditions( transaction: EntityManager, projectUuid: string, @@ -761,18 +528,6 @@ export class AccessService { } } - /** - * check POST-CONDITIONS for updating project access rights: - * - * - there must be at least one group with delete rights - * - * @param transaction - * @param projectUuid - * @private - * - * @throws ConflictException if the post-conditions are not met - * - */ private async checkProjectAccessModificationPostConditions( transaction: EntityManager, projectUuid: string, @@ -792,17 +547,6 @@ export class AccessService { } } - /** - * Update the access rights for a project - * - * This method is a transactional method that updates the access rights for a project. - * This function assumes that you pass a full newProjectAccess object, i.e. all access rights - * not passed in the newProjectAccess object will be removed. - * - * @param projectUuid - * @param newProjectAccess - * @param authHeader - */ async updateProjectAccess( projectUuid: string, newProjectAccess: ProjectAccessDto[], @@ -844,7 +588,7 @@ export class AccessService { ); } - return await this.getProjectAccesses(projectUuid); + return await this.accessQueryService.getProjectAccesses(projectUuid); } async setExpireDate( @@ -859,7 +603,6 @@ export class AccessService { user: { uuid: userUuid }, }, }); - // TODO: check how to properly set the expireDate to null in a type-safe way // @ts-ignore agu.expirationDate = expireDate === 'never' ? null : expireDate; const { uuid: membershipUuid } = @@ -946,56 +689,4 @@ export class AccessService { return groupMembershipEntityToDto(savedMembership); } - - async getAuditLogs(uuid: string): Promise { - const [logs, count] = - await this.accessGroupAuditService.getLogsForGroup(uuid); - - // Extract all unique UUIDs that need resolving - const uuidsToResolve = new Set(); - for (const log of logs) { - const details = log.details; - if (details.userUuid && !details.userName) { - uuidsToResolve.add(details.userUuid as string); - } - if (Array.isArray(details.userUuids)) { - for (const id of details.userUuids as string[]) { - uuidsToResolve.add(id); - } - } - } - - const resolvedUsers = await this.userService.resolveUsers([ - ...uuidsToResolve, - ]); - - return { - data: logs.map((log) => { - const details = { ...log.details }; - if (details.userUuid && !details.userName) { - details.userName = - resolvedUsers[details.userUuid as string] ?? - details.userUuid; - } - if (Array.isArray(details.userUuids)) { - details.affectedUsers = (details.userUuids as string[]).map( - (id) => ({ - uuid: id, - name: resolvedUsers[id] ?? id, - }), - ); - delete details.userUuids; - } - - return { - uuid: log.uuid, - createdAt: log.createdAt, - type: log.type, - details, - actor: log.actor ? userEntityToDto(log.actor) : undefined, - }; - }), - count, - }; - } } diff --git a/backend/src/services/access-query.service.ts b/backend/src/services/access-query.service.ts new file mode 100644 index 000000000..d54c934cb --- /dev/null +++ b/backend/src/services/access-query.service.ts @@ -0,0 +1,341 @@ +import { AuthHeader } from '@/endpoints/auth/parameter-decorator'; +import { + groupMembershipEntityToDto, + projectAccessEntityToDto, + userEntityToDto, +} from '@/serialization'; +import { + AccessGroupAuditLogsDto, + AccessGroupDto, + AccessGroupsDto, + ProjectAccessListDto, + ProjectWithAccessRightsDto, +} from '@kleinkram/api-dto'; +import { + AccessGroupAuditService, + AccessGroupEntity, +} from '@kleinkram/backend-common'; +import { GroupMembershipEntity } from '@kleinkram/backend-common/entities/auth/group-membership.entity'; +import { ProjectAccessEntity } from '@kleinkram/backend-common/entities/auth/project-access.entity'; +import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; +import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; +import { + AccessGroupConfig, + AccessGroupRights, + AccessGroupType, + UserRole, +} from '@kleinkram/shared'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, ILike, Repository } from 'typeorm'; +import logger from '../logger'; +import { UserService } from './user.service'; + +@Injectable() +export class AccessQueryService { + constructor( + @InjectRepository(UserEntity) + private userRepository: Repository, + @InjectRepository(AccessGroupEntity) + private accessGroupRepository: Repository, + @InjectRepository(GroupMembershipEntity) + private groupMembershipRepository: Repository, + @InjectRepository(ProjectEntity) + private projectRepository: Repository, + @InjectRepository(ProjectAccessEntity) + private projectAccessRepository: Repository, + private readonly configService: ConfigService, + private readonly accessGroupAuditService: AccessGroupAuditService, + private readonly userService: UserService, + ) {} + + async getAccessGroup( + uuid: string, + userUuid: string, + ): Promise { + const dbuser = await this.userRepository.findOneOrFail({ + where: { uuid: userUuid }, + }); + + if (dbuser.role !== UserRole.ADMIN) { + const isMember = + (await this.groupMembershipRepository + .createQueryBuilder('groupMembership') + .leftJoin('groupMembership.user', 'user') + .leftJoin('groupMembership.accessGroup', 'accessGroup') + .where('accessGroup.uuid = :uuid', { uuid }) + .andWhere('user.uuid = :userUuid', { userUuid }) + .getCount()) > 0; + + if (!isMember) { + const sharedProjectsCount = await this.projectAccessRepository + .createQueryBuilder('projectAccess') + .leftJoin('projectAccess.accessGroup', 'accessGroup') + .leftJoin('projectAccess.project', 'project') + .leftJoin('project.project_accesses', 'allProjectAccesses') + .leftJoin( + 'allProjectAccesses.accessGroup', + 'userAccessGroup', + ) + .leftJoin('userAccessGroup.memberships', 'memberships') + .leftJoin('memberships.user', 'user') + .where('accessGroup.uuid = :uuid', { uuid }) + .andWhere('user.uuid = :userUuid', { userUuid }) + .getCount(); + + if (sharedProjectsCount === 0) { + throw new ForbiddenException( + 'Access denied to this access group', + ); + } + } + } + + // check if the user can edit the access group (memberships[].canEditGroup) + const includeEmail = + (await this.groupMembershipRepository + .createQueryBuilder('groupMembership') + .leftJoinAndSelect('groupMembership.user', 'user') + .leftJoinAndSelect('groupMembership.accessGroup', 'accessGroup') + .where('accessGroup.uuid = :uuid', { uuid }) + .andWhere('groupMembership.user.uuid = :userUuid', { + userUuid, + }) + .andWhere('groupMembership.canEditGroup = true') + .getCount()) > 0; + + let accessGroupQuery = this.accessGroupRepository + .createQueryBuilder('accessGroup') + .withDeleted() + .leftJoinAndSelect('accessGroup.memberships', 'memberships') + .leftJoinAndSelect('memberships.user', 'user') + .leftJoinAndSelect( + 'accessGroup.project_accesses', + 'project_accesses', + ) + .leftJoinAndSelect('project_accesses.project', 'project') + .leftJoinAndSelect('accessGroup.creator', 'creator') + .where('accessGroup.uuid = :uuid', { uuid }); + + // we need to explicitly select the email field + if (includeEmail) + accessGroupQuery = accessGroupQuery.addSelect('user.email'); + + const rawAccessGroup = await accessGroupQuery.getOneOrFail(); + + return { + createdAt: rawAccessGroup.createdAt, + creator: rawAccessGroup.creator + ? userEntityToDto(rawAccessGroup.creator) + : null, + hidden: rawAccessGroup.hidden, + memberships: + rawAccessGroup.memberships?.map((membership) => + groupMembershipEntityToDto(membership, includeEmail), + ) ?? [], + name: rawAccessGroup.name, + type: rawAccessGroup.type, + updatedAt: rawAccessGroup.updatedAt, + uuid: rawAccessGroup.uuid, + projectAccesses: + rawAccessGroup.project_accesses?.map( + (value) => + ({ + createdAt: value.project?.createdAt, + description: value.project?.description, + updatedAt: value.project?.updatedAt, + name: value.project?.name, + uuid: value.project?.uuid, + rights: value.rights, + autoConvert: value.project?.autoConvert ?? false, + }) as ProjectWithAccessRightsDto, + ) ?? [], + emailPattern: + rawAccessGroup.type === AccessGroupType.AFFILIATION + ? this.configService + .getOrThrow('accessConfig') + .emails.find((emailConfig) => + emailConfig.access_groups.includes( + rawAccessGroup.uuid, + ), + )?.email + : undefined, + }; + } + + async hasProjectRights( + projectUUID: string, + auth: AuthHeader, + rights: AccessGroupRights = AccessGroupRights.WRITE, + ): Promise { + const dbuser = await this.userRepository.findOneOrFail({ + where: { uuid: auth.user.uuid }, + }); + if (dbuser.role === UserRole.ADMIN) { + return true; + } + + return this.projectRepository + .createQueryBuilder('project') + .leftJoin( + 'project_access_view_entity', + 'projectAccesses', + 'projectAccesses.projectuuid = project.uuid', + ) + .where('project.uuid = :uuid', { uuid: projectUUID }) + .andWhere('projectAccesses.rights >= :rights', { + rights: rights, + }) + .andWhere('projectAccesses.useruuid = :user_uuid', { + // eslint-disable-next-line @typescript-eslint/naming-convention + user_uuid: auth.user.uuid, + }) + .getExists(); + } + + async searchAccessGroup( + search: string, + type: AccessGroupType | undefined, + skip: number, + take: number, + ): Promise { + // we only list the access groups that are not hidden + const where: FindOptionsWhere = { + hidden: false, + }; + + if (type !== undefined) { + where.type = type; + } + + if (search !== '') { + where.name = ILike(`%${search}%`); + } + + const [accessGroups, count] = + await this.accessGroupRepository.findAndCount({ + where, + skip, + take, + relations: [ + 'memberships', + 'memberships.user', + 'project_accesses', + 'project_accesses.project', + 'creator', + ], + }); + + logger.debug(`Search access group with name containing '${search}'`); + logger.debug(`Found ${count.toString()} access groups`); + + const data: AccessGroupDto[] = accessGroups.map( + (accessGroup: AccessGroupEntity): AccessGroupDto => { + return { + creator: accessGroup.creator + ? userEntityToDto(accessGroup.creator) + : null, + memberships: + accessGroup.memberships?.map((membership) => + groupMembershipEntityToDto(membership), + ) ?? [], + createdAt: accessGroup.createdAt, + updatedAt: accessGroup.updatedAt, + uuid: accessGroup.uuid, + name: accessGroup.name, + type: accessGroup.type, + hidden: accessGroup.hidden, + projectAccesses: [], + emailPattern: + accessGroup.type === AccessGroupType.AFFILIATION + ? this.configService + .getOrThrow('accessConfig') + .emails.find((emailConfig) => + emailConfig.access_groups.includes( + accessGroup.uuid, + ), + )?.email + : undefined, + }; + }, + ); + + return { data, count, skip, take }; + } + + async getProjectAccesses( + projectUUID: string, + ): Promise { + const [access, count] = await this.projectAccessRepository.findAndCount( + { + where: { project: { uuid: projectUUID } }, + order: { accessGroup: { name: 'ASC' } }, + relations: [ + 'project', + 'accessGroup', + 'accessGroup.memberships', + ], + }, + ); + + return { + data: access.map((element) => projectAccessEntityToDto(element)), + count, + take: count, + skip: 0, + }; + } + + async getAuditLogs(uuid: string): Promise { + const [logs, count] = + await this.accessGroupAuditService.getLogsForGroup(uuid); + + // Extract all unique UUIDs that need resolving + const uuidsToResolve = new Set(); + for (const log of logs) { + const details = log.details; + if (details.userUuid && !details.userName) { + uuidsToResolve.add(details.userUuid as string); + } + if (Array.isArray(details.userUuids)) { + for (const id of details.userUuids as string[]) { + uuidsToResolve.add(id); + } + } + } + + const resolvedUsers = await this.userService.resolveUsers([ + ...uuidsToResolve, + ]); + + return { + data: logs.map((log) => { + const details = { ...log.details }; + if (details.userUuid && !details.userName) { + details.userName = + resolvedUsers[details.userUuid as string] ?? + details.userUuid; + } + if (Array.isArray(details.userUuids)) { + details.affectedUsers = (details.userUuids as string[]).map( + (id) => ({ + uuid: id, + name: resolvedUsers[id] ?? id, + }), + ); + delete details.userUuids; + } + + return { + uuid: log.uuid, + createdAt: log.createdAt, + type: log.type, + details, + actor: log.actor ? userEntityToDto(log.actor) : undefined, + }; + }), + count, + }; + } +} diff --git a/backend/src/services/file-lifecycle.service.ts b/backend/src/services/file-lifecycle.service.ts new file mode 100644 index 000000000..49f81783f --- /dev/null +++ b/backend/src/services/file-lifecycle.service.ts @@ -0,0 +1,601 @@ +import { TriggerService } from '@/services/trigger.service'; +import { TemporaryFileAccessesDto, UpdateFile } from '@kleinkram/api-dto'; +import { FileAuditService } from '@kleinkram/backend-common/audit/file-audit.service'; +import { redis } from '@kleinkram/backend-common/consts'; +import { ActionEntity } from '@kleinkram/backend-common/entities/action/action.entity'; +import { CategoryEntity } from '@kleinkram/backend-common/entities/category/category.entity'; +import { FileEntity } from '@kleinkram/backend-common/entities/file/file.entity'; +import { IngestionJobEntity } from '@kleinkram/backend-common/entities/file/ingestion-job.entity'; +import { MissionEntity } from '@kleinkram/backend-common/entities/mission/mission.entity'; +import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; +import env from '@kleinkram/backend-common/environment'; +import { + IStorageBucket, + StorageCredentials, +} from '@kleinkram/backend-common/modules/storage/types'; +import { + FileEventType, + FileOrigin, + FileState, + FileType, + TriggerEvent, +} from '@kleinkram/shared'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, + OnModuleInit, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import Queue from 'bull'; +import { + DataSource, + In, + MoreThan, + QueryFailedError, + Repository, +} from 'typeorm'; +import logger from '../logger'; + +const FILE_EXTENSION_TO_FILE_TYPE_MAP: ReadonlyMap = new Map([ + ['.bag', FileType.BAG], + ['.mcap', FileType.MCAP], + ['.yaml', FileType.YAML], + ['.yml', FileType.YAML], + ['.svo2', FileType.SVO2], + ['.tum', FileType.TUM], + ['.db3', FileType.DB3], +]); + +@Injectable() +export class FileLifecycleService implements OnModuleInit { + private fileCleanupQueue!: Queue.Queue; + + constructor( + @InjectRepository(FileEntity) + private fileRepository: Repository, + @InjectRepository(MissionEntity) + private missionRepository: Repository, + @InjectRepository(UserEntity) + private userRepository: Repository, + @InjectRepository(CategoryEntity) + private categoryRepository: Repository, + @Inject('DataStorageBucket') + private readonly dataStorage: IStorageBucket, + private readonly dataSource: DataSource, + private readonly auditService: FileAuditService, + private readonly triggerService: TriggerService, + ) {} + + onModuleInit(): void { + this.fileCleanupQueue = new Queue('file-cleanup', { + redis, + }); + } + + async update( + uuid: string, + file: UpdateFile, + actor?: UserEntity, + action?: ActionEntity, + ): Promise { + logger.debug(`Updating file with uuid: ${uuid}`); + + const databaseFile = await this.fileRepository.findOneOrFail({ + where: { uuid }, + relations: { mission: { project: true } }, + }); + + if (!databaseFile.mission) throw new Error('Mission not found!'); + if (!databaseFile.mission.project) + throw new Error('Project not found!'); + + const oldFilename = databaseFile.filename; + const isRenamed = file.filename !== oldFilename; + + // validate file ending + const validExtensions = [...FILE_EXTENSION_TO_FILE_TYPE_MAP.entries()] + .filter(([, type]) => type === databaseFile.type) + .map(([extension]) => extension); + + if ( + !validExtensions.some((extension) => + file.filename.endsWith(extension), + ) + ) { + throw new BadRequestException( + `File ending must be one of: ${validExtensions.join(', ')}`, + ); + } + + databaseFile.filename = file.filename; + databaseFile.date = file.date; + + // Handle Mission Move via Update + let oldMissionUuid: string | undefined; + if ( + file.missionUuid && + file.missionUuid !== databaseFile.mission.uuid + ) { + oldMissionUuid = databaseFile.mission.uuid; + const newMission = await this.missionRepository.findOneOrFail({ + where: { uuid: file.missionUuid }, + relations: ['project'], + }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (newMission) databaseFile.mission = newMission; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (file.categories) { + databaseFile.categories = await this.categoryRepository.find({ + where: { uuid: In(file.categories) }, + }); + } + + await this.dataSource + .transaction(async (transactionalEntityManager) => { + // [Existing Transaction Logic] + await transactionalEntityManager.save(FileEntity, databaseFile); + }) + .catch((error: unknown) => { + // [Existing Error Handling] + throw error; + }); + + // Log Rename Event + if (isRenamed) { + await this.auditService.log( + FileEventType.RENAMED, + { + fileUuid: databaseFile.uuid, + filename: databaseFile.filename, + missionUuid: databaseFile.mission.uuid, + ...(actor ? { actor } : {}), + ...(action ? { action } : {}), + details: { oldFilename, newFilename: file.filename }, + }, + true, + ); + await this.triggerService.addFileEvent( + databaseFile.uuid, + TriggerEvent.RENAME, + ); + } + + // Log Move Event (if done via update) + if (oldMissionUuid) { + await this.auditService.log( + FileEventType.MOVED, + { + fileUuid: databaseFile.uuid, + filename: databaseFile.filename, + missionUuid: databaseFile.mission.uuid, + ...(actor ? { actor } : {}), + details: { + fromMission: oldMissionUuid, + toMission: file.missionUuid, + }, + }, + true, + ); + await this.triggerService.addFileEvent( + databaseFile.uuid, + TriggerEvent.MOVE, + ); + } + + await this.dataStorage.addTags(databaseFile.uuid, { + // @ts-expect-error + projectUuid: databaseFile.mission.project.uuid, + missionUuid: databaseFile.mission.uuid, + filename: databaseFile.filename, + }); + return this.fileRepository.findOne({ + where: { uuid }, + relations: ['mission', 'mission.project'], + }); + } + + async moveFiles( + fileUUIDs: string[], + missionUUID: string, + actor?: UserEntity, + action?: ActionEntity, + ): Promise { + await Promise.all( + fileUUIDs.map(async (uuid) => { + try { + const file = await this.fileRepository.findOneOrFail({ + where: { uuid }, + relations: ['mission'], + }); + + const oldMissionUuid = file.mission?.uuid; + + file.mission = { uuid: missionUUID } as MissionEntity; + await this.fileRepository.save(file); + + // Log Move Event + await this.auditService.log( + FileEventType.MOVED, + { + fileUuid: uuid, + filename: file.filename, + missionUuid: missionUUID, + ...(actor ? { actor } : {}), + ...(action ? { action } : {}), + details: { + fromMission: oldMissionUuid, + toMission: missionUUID, + }, + }, + true, + ); + await this.triggerService.addFileEvent( + uuid, + TriggerEvent.MOVE, + ); + + // ... [Existing Tag Update Logic] ... + const newFile = await this.fileRepository.findOneOrFail({ + where: { uuid }, + relations: ['mission', 'mission.project'], + }); + await this.dataStorage.addTags(file.uuid, { + filename: file.filename, + missionUuid: missionUUID, + projectUuid: newFile.mission?.project?.uuid ?? '', + }); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.error(`Error moving file ${uuid}: ${error}`); + } + }), + ); + } + + async deleteFile( + uuid: string, + actor?: UserEntity, + action?: ActionEntity, + ): Promise { + if (!uuid) throw new BadRequestException('UUID is required'); + + logger.debug(`Deleting file with uuid: ${uuid}`); + + const file = await this.fileRepository.findOne({ + where: { uuid }, + relations: ['mission'], + }); + + if (file) { + await this.auditService.log( + FileEventType.DELETED, + { + fileUuid: uuid, + filename: file.filename, + missionUuid: file.mission?.uuid ?? '', + ...(actor ? { actor } : {}), + ...(action ? { action } : {}), + details: { snapshot: 'File deleted from DB and Storage' }, + }, + true, + ); + } + + await this.fileRepository.manager.transaction( + async (transactionalEntityManager) => { + // [Existing Deletion Logic] + const fileToDelete = + await transactionalEntityManager.findOneOrFail(FileEntity, { + where: { uuid }, + }); + await this.dataStorage + .deleteFile(fileToDelete.uuid) + .catch(() => { + logger.error( + `File ${fileToDelete.uuid} not found in storage, deleting from database only!`, + ); + }); + + await transactionalEntityManager.softRemove(fileToDelete); + }, + ); + + logger.debug(`File with uuid ${uuid} deleted`); + } + + async isUploading(userUUID: string): Promise { + return this.fileRepository + .findOne({ + where: { + state: FileState.UPLOADING, + createdAt: MoreThan( + new Date(Date.now() - 12 * 60 * 60 * 1000), + ), + creator: { uuid: userUUID }, + }, + }) + .then((r) => !!r); + } + + async getTemporaryAccess( + filenames: string[], + missionUUID: string, + userUUID: string, + action?: ActionEntity, + uploadSource = 'Web Interface', + ): Promise { + const mission = await this.missionRepository.findOneOrFail({ + where: { uuid: missionUUID }, + relations: ['project'], + }); + const user = await this.userRepository.findOneOrFail({ + where: { uuid: userUUID }, + }); + + return await this.dataSource.transaction(async (manager) => { + // Deduplicate filenames to avoid self-collisions + const uniqueFilenames = [...new Set(filenames)]; + const credentials: { + bucket: string | null; + fileName: string; + fileUUID: string | null; + accessCredentials: StorageCredentials | null; + error?: string | null; + }[] = []; + + const invalidFiles: { filename: string; error: string }[] = []; + + // Check for existing files first to avoid transaction abortion on duplicate key error + const existingFiles = await manager.find(FileEntity, { + where: { + filename: In(uniqueFilenames), + mission: { + uuid: missionUUID, + }, + }, + }); + + const existingFilenames = new Set( + existingFiles.map((f) => f.filename), + ); + + for (const filename of uniqueFilenames) { + const emptyCredentials: { + bucket: string | null; + fileName: string; + fileUUID: string | null; + accessCredentials: StorageCredentials | null; + error: string | null; + queueUUID?: string; + } = { + bucket: null, + fileName: filename, + + fileUUID: null, + + accessCredentials: null, + + error: null, + }; + + // eslint-disable-next-line @typescript-eslint/naming-convention + const supported_file_endings = [ + ...FILE_EXTENSION_TO_FILE_TYPE_MAP.keys(), + ]; + + if ( + !supported_file_endings.some((ending) => + filename.endsWith(ending), + ) + ) { + emptyCredentials.error = 'Invalid file ending'; + credentials.push(emptyCredentials); + continue; + } + + const matchingFileType = supported_file_endings.find((ending) => + filename.endsWith(ending), + ); + if (matchingFileType === undefined) + throw new UnsupportedMediaTypeException(); + const fileType: FileType | undefined = + FILE_EXTENSION_TO_FILE_TYPE_MAP.get(matchingFileType); + if (fileType === undefined) + throw new UnsupportedMediaTypeException(); + + if (existingFilenames.has(filename)) { + invalidFiles.push({ + filename, + error: 'File already exists', + }); + continue; + } + + try { + // Use a nested transaction (savepoint) for each file + await manager.transaction(async (nestedManager) => { + const file = await nestedManager.save( + FileEntity, + nestedManager.create(FileEntity, { + date: new Date(), + size: 0, + filename, + mission, + creator: user, + type: fileType, + state: FileState.UPLOADING, + origin: FileOrigin.UPLOAD, + }), + ); + + await this.auditService.log( + FileEventType.UPLOAD_STARTED, + { + fileUuid: file.uuid, + filename: file.filename, + missionUuid: missionUUID, + actor: user, + ...(action ? { action } : {}), + details: { + origin: FileOrigin.UPLOAD, + source: uploadSource, + }, + }, + true, + ); + + credentials.push({ + bucket: env.S3_DATA_BUCKET_NAME, + fileUUID: file.uuid, + fileName: filename, + accessCredentials: + await this.dataStorage.generateTemporaryCredential( + file.uuid, + ), + }); + + // Add to local set to catch duplicates in the same batch + existingFilenames.add(filename); + }); + } catch (error: unknown) { + if ( + error instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + error.driverError.code === '23505' + ) { + invalidFiles.push({ + filename, + error: 'File already exists', + }); + // Also add to set so we don't try again if it appears again in list (though deduplication handles this) + existingFilenames.add(filename); + } else { + throw error; + } + } + } + + if (invalidFiles.length > 0) { + logger.warn( + `getTemporaryAccess: user="${userUUID}" mission="${missionUUID}" ` + + `denied upload for ${invalidFiles.length.toString()} already-existing file(s): ` + + invalidFiles.map((f) => `"${f.filename}"`).join(', '), + ); + throw new ConflictException({ + message: 'Files already exist', + errors: invalidFiles, + }); + } + + return { + // TODO: fix typing + // @ts-ignore + data: credentials, + count: credentials.length, + skip: 0, + take: credentials.length, + }; + }); + } + + async cancelUpload( + uuids: string[], + missionUUID: string, + userUUID: string, + ): Promise { + // Cleanup cannot be done synchronously as this takes too long; the request is sent on unload; delaying the onUnload is difficult and discouraged + return this.fileCleanupQueue.add('cancelUpload', { + uuids, + missionUUID, + userUUID, + }); + } + + async deleteMultiple( + fileUUIDs: string[], + missionUUID: string, + ): Promise { + if (fileUUIDs.length === 0) return; + + const uniqueFilesUuids = [...new Set(fileUUIDs)]; + + await this.fileRepository.manager.transaction( + async (transactionalEntityManager) => { + const files = await transactionalEntityManager.find( + FileEntity, + { + where: { + uuid: In(uniqueFilesUuids), + mission: { uuid: missionUUID }, + }, + }, + ); + + const uniqueDatabaseFilesUuids = [ + ...new Set(files.map((f) => f.uuid)), + ]; + if ( + uniqueDatabaseFilesUuids.length !== uniqueFilesUuids.length + ) { + throw new NotFoundException( + 'Some files not found, aborting', + ); + } + + // Delete potentially running ingestion jobs + await transactionalEntityManager.softDelete( + IngestionJobEntity, + { + identifier: In(uniqueDatabaseFilesUuids), + }, + ); + + await Promise.all( + files.map(async (file) => { + await this.dataStorage + .deleteFile(file.uuid) + .catch(() => { + logger.error( + `File ${file.uuid} not found in storage, deleting from database only!`, + ); + }); + }), + ); + + await transactionalEntityManager.softDelete( + FileEntity, + uniqueDatabaseFilesUuids, + ); + }, + ); + } + + async reextractMissingTopics(): Promise { + const filesToFix = await this.fileRepository + .createQueryBuilder('file') + .leftJoin('file.topics', 'topic') + .where('file.type = :type', { type: FileType.BAG }) + .andWhere('file.state = :state', { state: FileState.OK }) + .andWhere('topic.uuid IS NULL') + .select(['file.uuid', 'file.filename']) + .getMany(); + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.debug(`Found ${filesToFix.length} bag files missing topics.`); + + for (const file of filesToFix) { + await this.fileCleanupQueue.add('extract-topics-repair', { + fileUuid: file.uuid, + filename: file.filename, + }); + } + + return filesToFix.length; + } +} diff --git a/backend/src/services/file-query.service.ts b/backend/src/services/file-query.service.ts new file mode 100644 index 000000000..0f3cd43b6 --- /dev/null +++ b/backend/src/services/file-query.service.ts @@ -0,0 +1,849 @@ +import { + addAccessConstraintsToFileQuery, + addAccessConstraintsToMissionQuery, + addAccessConstraintsToProjectQuery, +} from '@/endpoints/auth/auth-helper'; +import { fileEntityToDto, fileEntityToDtoWithTopic } from '@/serialization'; +import { + FileEventsDto, + FileExistsResponseDto, + FileQueryDto, + FilesDto, + FileWithTopicDto, + SortOrder, +} from '@kleinkram/api-dto'; +import { FileEventEntity } from '@kleinkram/backend-common/entities/file/file-event.entity'; +import { FileEntity } from '@kleinkram/backend-common/entities/file/file.entity'; +import { MissionEntity } from '@kleinkram/backend-common/entities/mission/mission.entity'; +import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; +import { TagTypeEntity } from '@kleinkram/backend-common/entities/tagType/tag-type.entity'; +import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; +import { + DataType, + FileState, + FileType, + HealthStatus, + UserRole, +} from '@kleinkram/shared'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, In, Repository, SelectQueryBuilder } from 'typeorm'; +import logger from '../logger'; +import { + addFileFilters, + addMissionFilters, + addProjectFilters, + addSort, + convertGlobToLikePattern, +} from './utilities'; + +const FIND_MANY_SORT_KEYS = { + name: 'file.filename', + filename: 'file.filename', + createdAt: 'file.createdAt', + updatedAt: 'file.updatedAt', + creator: 'user.name', + size: 'file.size', + state: 'file.state', + date: 'file.date', + + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.filename': 'file.filename', + + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.createdAt': 'file.createdAt', + + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.updatedAt': 'file.updatedAt', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.size': 'file.size', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.state': 'file.state', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'file.date': 'file.date', +}; + +@Injectable() +export class FileQueryService { + constructor( + @InjectRepository(FileEntity) + private fileRepository: Repository, + @InjectRepository(MissionEntity) + private missionRepository: Repository, + @InjectRepository(ProjectEntity) + private projectRepository: Repository, + @InjectRepository(UserEntity) + private userRepository: Repository, + @InjectRepository(TagTypeEntity) + private tagTypeRepository: Repository, + @InjectRepository(FileEventEntity) + private eventRepo: Repository, + ) {} + + async findMany( + query: FileQueryDto, + userUuid: string, + apiKeyMissionUuid?: string, + ): Promise { + const user = await this.userRepository.findOneOrFail({ + where: { uuid: userUuid }, + }); + + // Start building the query to fetch *only* IDs + let idQuery = this.fileRepository + .createQueryBuilder('file') + .select('file.uuid') // Select only the UUID + .leftJoin('file.mission', 'mission') + .leftJoin('mission.project', 'project') + .leftJoin('file.topics', 'topic') + .leftJoin('file.creator', 'creator'); + + // ADMIN users see all, others are constrained + if (user.role !== UserRole.ADMIN) { + idQuery = addAccessConstraintsToFileQuery(idQuery, userUuid); + } + + // Apply project filters + const projectUuids = + query.projectUuids ?? + (query.projectUUID ? [query.projectUUID] : []); + if ( + projectUuids.length > 0 || + (query.projectPatterns && query.projectPatterns.length > 0) + ) { + idQuery = addProjectFilters( + idQuery, + this.projectRepository, + projectUuids, + query.projectPatterns ?? [], + query.exactMatch === 'true', + ); + } + + // Apply mission filters + const missionUuids = + query.missionUuids ?? + (query.missionUUID + ? [query.missionUUID] + : apiKeyMissionUuid + ? [apiKeyMissionUuid] + : []); + if ( + missionUuids.length > 0 || + (query.missionPatterns && query.missionPatterns.length > 0) || + (query.metadata && Object.keys(query.metadata).length > 0) + ) { + idQuery = addMissionFilters( + idQuery, + this.missionRepository, + missionUuids, + query.missionPatterns ?? [], + query.metadata ?? {}, + ); + } + + // Apply simple filters + if (query.fileName) { + logger.debug(`Filtering files by filename: ${query.fileName}`); + const tokens = query.fileName.trim().split(/\s+/); + + if (tokens.length > 0) { + idQuery.andWhere( + new Brackets((qb) => { + for (const [index, token] of tokens.entries()) { + qb.andWhere( + `file.filename ILIKE :fileName_${String(index)}`, + { + [`fileName_${String(index)}`]: `%${token}%`, + }, + ); + } + }), + ); + } + } + + if ( + (query.fileUuids && query.fileUuids.length > 0) || + (query.filePatterns && query.filePatterns.length > 0) + ) { + idQuery = addFileFilters( + idQuery, + this.fileRepository, + query.fileUuids ?? [], + query.filePatterns ?? [], + ); + } + + const fileExtensions = query.fileExtensions; + if (fileExtensions && fileExtensions.length > 0) { + idQuery.andWhere( + new Brackets((qb) => { + for (const [index, extension] of fileExtensions.entries()) { + qb.orWhere(`file.filename LIKE :ext_${String(index)}`, { + [`ext_${String(index)}`]: `%${extension}`, + }); + } + }), + ); + } + + if (query.startDate) { + logger.debug( + `Filtering files by start date: ${query.startDate.toString()}`, + ); + idQuery.andWhere('file.date >= :startDate', { + startDate: query.startDate, + }); + } + + if (query.endDate) { + logger.debug( + `Filtering files by end date: ${query.endDate.toString()}`, + ); + idQuery.andWhere('file.date <= :endDate', { + endDate: query.endDate, + }); + } + + // Apply complex filters via helper methods + this._applyFileTypeFilter(idQuery, query.fileTypes); + this._applyTopicFilter(idQuery, query.topics, query.matchAllTopics); + this._applyMessageDatatypeFilter(idQuery, query.messageDatatypes); + + const topicPatterns = query.topicPatterns; + if (topicPatterns && topicPatterns.length > 0) { + idQuery.andWhere( + new Brackets((qb) => { + for (const [index, pat] of topicPatterns.entries()) { + qb.orWhere( + `topic.name ILIKE :topicPat_${String(index)}`, + { + [`topicPat_${String(index)}`]: + convertGlobToLikePattern(pat), + }, + ); + } + }), + ); + } + + if (query.health) { + logger.debug(`Filtering files by health: ${query.health}`); + switch (query.health) { + case HealthStatus.HEALTHY: { + idQuery.andWhere('file.state IN (:...healthyStates)', { + healthyStates: [FileState.OK, FileState.FOUND], + }); + break; + } + case HealthStatus.UNHEALTHY: { + idQuery.andWhere('file.state IN (:...unhealthyStates)', { + unhealthyStates: [ + FileState.ERROR, + FileState.CONVERSION_ERROR, + FileState.LOST, + FileState.CORRUPTED, + ], + }); + break; + } + case HealthStatus.UPLOADING: { + idQuery.andWhere('file.state = :uploadingState', { + uploadingState: FileState.UPLOADING, + }); + break; + } + } + } + + const categoryUUIDs = query.categories + ? query.categories.split(',') + : []; + if (categoryUUIDs.length > 0) { + logger.debug( + `Filtering files by categories: ${query.categories ?? ''}`, + ); + idQuery + .innerJoin('file.categories', 'category') + .andWhere('category.uuid IN (:...categoryUUIDs)', { + categoryUUIDs, + }); + } + const categoryPatterns = query.categoryPatterns; + if (categoryPatterns && categoryPatterns.length > 0) { + if (categoryUUIDs.length === 0) { + idQuery.innerJoin('file.categories', 'category'); + } + idQuery.andWhere( + new Brackets((qb) => { + for (const [index, pat] of categoryPatterns.entries()) { + qb.orWhere( + `category.name ILIKE :catPat_${String(index)}`, + { + [`catPat_${String(index)}`]: + convertGlobToLikePattern(pat), + }, + ); + } + }), + ); + } + + // The tag filter is async, so it must be awaited + if (query.tags && Object.keys(query.tags).length > 0) { + await this._applyTagFilter(idQuery, query.tags); + } + + // Group by file.uuid to deduplicate results from joins + // and allow 'HAVING' clauses for topics and tags + idQuery.groupBy('file.uuid'); + + const sortField = query.sort ?? query.sortBy ?? 'createdAt'; + let order = query.sortOrder; + if (query.sortDirection) { + order = + query.sortDirection === 'DESC' ? SortOrder.DESC : SortOrder.ASC; + } + + idQuery = addSort(idQuery, FIND_MANY_SORT_KEYS, sortField, order); + + const take = query.take; + const skip = query.skip; + idQuery.offset(skip).limit(take); + + const [fileIdObjects, count] = await idQuery.getManyAndCount(); + + if (fileIdObjects.length === 0) { + logger.silly('No files found'); + return { + count, + data: [], + take, + skip, + }; + } + + const fileIds = fileIdObjects.map((file) => file.uuid); + + // It must re-apply joins (for selection) and sorting. + let filesQuery = this.fileRepository + .createQueryBuilder('file') + .leftJoinAndSelect('file.mission', 'mission') + .leftJoinAndSelect('mission.project', 'project') + .leftJoinAndSelect('file.topics', 'topic') + .leftJoinAndSelect('file.creator', 'creator') + .leftJoinAndSelect('file.categories', 'category') + .where('file.uuid IN (:...fileIds)', { fileIds }); + + filesQuery = addSort(filesQuery, FIND_MANY_SORT_KEYS, sortField, order); + + const files = await filesQuery.getMany(); + + return { + count, + data: files.map((element) => fileEntityToDto(element)), + take, + skip, + }; + } + + async findOne(uuid: string): Promise { + const file = await this.fileRepository.findOneOrFail({ + where: { uuid }, + relations: [ + 'mission', + 'topics', + 'mission.project', + 'creator', + 'categories', + 'parent', + 'parent.topics', + 'derivedFiles', + 'derivedFiles.topics', + ], + }); + + return fileEntityToDtoWithTopic(file); + } + + async findOneByName( + missionUUID: string, + name: string, + ): Promise { + return this.fileRepository.findOne({ + where: { mission: { uuid: missionUUID }, filename: name }, + relations: ['creator'], + }); + } + + async exists(fileUUID: string): Promise { + return { + exists: await this.fileRepository.exists({ + where: { uuid: fileUUID }, + }), + uuid: fileUUID, + }; + } + + async checkResourceAccess( + projectUuids: string[], + missionUuids: string[], + userUuid: string, + ): Promise { + // Verify Projects + if (projectUuids.length > 0) { + const uniqueProjectUuids = [...new Set(projectUuids)]; + + let query = this.projectRepository.createQueryBuilder('project'); + + // Filter by the specific requested IDs + query.where('project.uuid IN (:...uuids)', { + uuids: uniqueProjectUuids, + }); + + // Apply standard security constraints (Admins see all; Users see their own) + query = addAccessConstraintsToProjectQuery(query, userUuid); + + // Fetch allowed IDs + const foundProjects = await query.select('project.uuid').getMany(); + const foundUuids = new Set(foundProjects.map((p) => p.uuid)); + + // Calculate missing + const missing = uniqueProjectUuids.filter( + (id) => !foundUuids.has(id), + ); + + if (missing.length > 0) { + throw new NotFoundException( + `The following Project UUIDs do not exist or you do not have access: ${missing.join(', ')}`, + ); + } + } + + // Verify Missions + if (missionUuids.length > 0) { + const uniqueMissionUuids = [...new Set(missionUuids)]; + + let query = this.missionRepository + .createQueryBuilder('mission') + .select('mission.uuid') + .leftJoin('mission.project', 'project'); + + // Filter by the specific requested IDs + query.where('mission.uuid IN (:...uuids)', { + uuids: uniqueMissionUuids, + }); + + // Apply standard security constraints + query = addAccessConstraintsToMissionQuery(query, userUuid); + + // Fetch allowed IDs + const foundMissions = await query.select('mission.uuid').getMany(); + const foundUuids = new Set(foundMissions.map((m) => m.uuid)); + + // Calculate missing + const missing = uniqueMissionUuids.filter( + (id) => !foundUuids.has(id), + ); + + if (missing.length > 0) { + throw new NotFoundException( + `The following Mission UUIDs do not exist or you do not have access: ${missing.join(', ')}`, + ); + } + } + } + + async checkResourceAccessByName( + projectNamePatterns: string[], + missionNamePatterns: string[], + userUuid: string, + exactMatch = false, + ): Promise { + logger.debug( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Checking resource access by name for user ${userUuid}. Projects: ${projectNamePatterns}, Missions: ${missionNamePatterns}, Exact: ${exactMatch}`, + ); + + // We use addProjectFilters which supports exactMatch natively + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (projectNamePatterns && projectNamePatterns.length > 0) { + const missingPatterns: string[] = []; + + for (const pattern of projectNamePatterns) { + let query = + this.projectRepository.createQueryBuilder('project'); + + query = addAccessConstraintsToProjectQuery(query, userUuid); + query = addProjectFilters( + query, + this.projectRepository, + [], + [pattern], + exactMatch, + ); + + const count = await query.getCount(); + if (count === 0) { + missingPatterns.push(pattern); + } + } + + if (missingPatterns.length > 0) { + throw new NotFoundException( + `The following Project patterns matched no accessible resources: ${missingPatterns.join(', ')}`, + ); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (missionNamePatterns && missionNamePatterns.length > 0) { + const missingPatterns: string[] = []; + + for (const pattern of missionNamePatterns) { + let query = this.missionRepository + .createQueryBuilder('mission') + .leftJoin('mission.project', 'project'); + + query = addAccessConstraintsToMissionQuery(query, userUuid); + + if (exactMatch) { + query.andWhere('LOWER(mission.name) = LOWER(:pattern)', { + pattern, + }); + } else { + const likePattern = convertGlobToLikePattern(pattern); + // Use standard wildcard match (case-insensitive) + query.andWhere('LOWER(mission.name) LIKE :pattern', { + pattern: `%${likePattern.toLowerCase()}%`, + }); + } + + const count = await query.getCount(); + if (count === 0) { + missingPatterns.push(pattern); + } + } + + if (missingPatterns.length > 0) { + throw new NotFoundException( + `The following Mission patterns matched no accessible resources: ${missingPatterns.join(', ')}`, + ); + } + } + } + + async getFileEvents(fileUuid: string): Promise { + const events = await this.eventRepo.find({ + where: { + file: { uuid: fileUuid }, + }, + relations: ['actor', 'action', 'action.template', 'action.creator'], + order: { createdAt: 'DESC' }, + }); + + return { + count: events.length, + data: + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + events.map((event) => ({ + uuid: event.uuid, + type: event.type, + createdAt: event.createdAt, + details: event.details, + actor: event.actor + ? { + uuid: event.actor.uuid, + name: event.actor.name, + avatarUrl: null, + email: null, + } + : undefined, + action: event.action + ? { + uuid: event.action.uuid, + + name: event.action.template?.name, + + creator: event.action.creator + ? { + uuid: event.action.creator.uuid, + + name: event.action.creator.name, + avatarUrl: null, + email: null, + } + : undefined, + } + : undefined, + })) ?? [], + } as FileEventsDto; + } + + async getActionFileEvents(actionUuid: string): Promise { + const events = await this.eventRepo.find({ + where: { + action: { uuid: actionUuid }, + }, + relations: [ + 'actor', + 'action', + 'action.template', + 'file', + 'file.mission', + 'file.mission.project', + ], + order: { createdAt: 'DESC' }, + }); + + return { + count: events.length, + data: + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + events.map((event) => ({ + uuid: event.uuid, + type: event.type, + createdAt: event.createdAt, + details: event.details, + actor: event.actor + ? { + uuid: event.actor.uuid, + name: event.actor.name, + avatarUrl: null, + email: null, + } + : undefined, + action: event.action + ? { + uuid: event.action.uuid, + + name: event.action.template?.name, + } + : undefined, + file: event.file + ? { + uuid: event.file.uuid, + filename: event.file.filename, + missionUuid: event.file.mission?.uuid ?? '', + missionName: event.file.mission?.name ?? '', + projectUuid: + event.file.mission?.project?.uuid ?? '', + projectName: + event.file.mission?.project?.name ?? '', + } + : undefined, + })) ?? [], + } as FileEventsDto; + } + + private _applyFileTypeFilter( + query: SelectQueryBuilder, + fileTypes: string | undefined, + ): void { + if (!fileTypes) { + return; + } + + const requestedTypes = fileTypes.split(','); + const requestedTypesUpper = requestedTypes.map((t) => t.toUpperCase()); + + // If 'ALL' is requested, do nothing (apply no filter) + if (requestedTypesUpper.includes(FileType.ALL)) { + return; + } + + // Build a lookup map of valid enum values (e.g., "mcap" -> "MCAP") + const validTypesLookup = new Map(); + for (const type of Object.values(FileType).filter( + (_type) => _type !== FileType.ALL, + )) { + validTypesLookup.set(type.toLowerCase(), type); + } + // Manually add 'yml' to map to YAML since we merged them + validTypesLookup.set('yml', FileType.YAML); + + // Map requested types to their valid, cased enum values and deduplicate + const typesToFilter = [ + ...new Set( + requestedTypes + .map((requestType) => + validTypesLookup.get(requestType.toLowerCase()), + ) + .filter((type): type is string => !!type), // Filter out undefined + ), + ]; + + if (typesToFilter.length > 0) { + logger.debug( + `Filtering files by types: ${typesToFilter.join(',')}`, + ); + query.andWhere('file.type IN (:...fileTypes)', { + fileTypes: typesToFilter, + }); + } else { + // No valid types were provided (e.g., "garbage,foo") + logger.warn(`No valid file types found in filter: ${fileTypes}`); + + query.andWhere('1 = 0'); // Force query to return no results + } + } + + private _applyTopicFilter( + query: SelectQueryBuilder, + topics: string | undefined, + matchAllTopics: boolean | undefined, + ): void { + if (!topics) { + return; + } + + const splitTopics = topics.split(',').filter((t) => t.length > 0); + if (splitTopics.length === 0) { + return; + } + + // Filter files that have *at least one* of the topics + query.andWhere('topic.name IN (:...splitTopics)', { + splitTopics, + }); + + // If 'matchAllTopics' is true, add a HAVING clause + // to ensure the file has *all* requested topics. + if (matchAllTopics) { + query.having('COUNT(DISTINCT topic.name) = :topicCount', { + topicCount: splitTopics.length, + }); + } + } + + private _applyMessageDatatypeFilter( + query: SelectQueryBuilder, + messageDatatype: string | undefined, + ): void { + if (!messageDatatype) { + return; + } + + const splitMessageDatatype = messageDatatype + .split(',') + .filter((t) => t.length > 0); + if (splitMessageDatatype.length === 0) { + return; + } + + // Filter files that have *at least one* of the message datatypes + query.andWhere('topic.type IN (:...splitMessageDatatype)', { + splitMessageDatatype, + }); + } + + private async _applyTagFilter( + query: SelectQueryBuilder, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tags: Record, + ): Promise { + const tagTypeUUIDs = Object.keys(tags); + if (tagTypeUUIDs.length === 0) { + return; + } + + const tagTypes = await this.tagTypeRepository.find({ + where: { uuid: In(tagTypeUUIDs) }, + }); + const tagTypeMap = new Map(tagTypes.map((t) => [t.uuid, t])); + + // Add the necessary joins for tag filtering + query + .leftJoin('mission.tags', 'tag') + .leftJoin('tag.tagType', 'tagtype'); + + const tagWhereClauses: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tagParameters: Record = {}; + const validTagNames = new Set(); + let validTagCount = 0; + + for (const uuid of tagTypeUUIDs) { + const tagtype = tagTypeMap.get(uuid); + if (!tagtype) { + logger.warn(`Invalid tag type UUID in filter: ${uuid}`); + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const value = tags[uuid]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [column, processedValue] = this._getTagColumnAndValue( + tagtype.datatype, + value, + ); + + if (!column) { + logger.warn(`Unknown data type for tag type ${uuid}`); + continue; + } + + // Create unique parameter names for this condition + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const uuidParameter = `tagtype${validTagCount}`; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const valueParameter = `tagval${validTagCount}`; + + // Build the clause: (tagtype.uuid = :uuid AND tag.VALUE_COLUMN = :value) + tagWhereClauses.push( + `(tagtype.uuid = :${uuidParameter} AND tag.${column} = :${valueParameter})`, + ); + tagParameters[uuidParameter] = uuid; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + tagParameters[valueParameter] = processedValue; + + validTagCount++; + validTagNames.add(tagtype.name); + } + + if (validTagCount === 0) { + // All provided tag filters were invalid + query.andWhere('1 = 0'); // Return no results + return; + } + + query.andWhere( + new Brackets((qb) => { + for (const clause of tagWhereClauses) qb.orWhere(clause); + }), + tagParameters, + ); + + query.having('COUNT(DISTINCT tagtype.name) = :tagCount', { + tagCount: validTagNames.size, + }); + } + + private _getTagColumnAndValue( + dataType: DataType, + value: T, + ): [string | null, T | undefined] { + switch (dataType) { + case DataType.BOOLEAN: { + return ['BOOLEAN', value]; + } + case DataType.DATE: { + return ['DATE', value]; + } + case DataType.LOCATION: { + return ['LOCATION', value]; + } + case DataType.NUMBER: { + return ['NUMBER', value]; + } + case DataType.STRING: + case DataType.LINK: { + return ['STRING', value]; + } + default: { + return [null, undefined]; + } + } + } +} diff --git a/backend/src/services/file-storage.service.ts b/backend/src/services/file-storage.service.ts new file mode 100644 index 000000000..30223cdd9 --- /dev/null +++ b/backend/src/services/file-storage.service.ts @@ -0,0 +1,164 @@ +import { StorageOverviewDto } from '@kleinkram/api-dto'; +import { FileAuditService } from '@kleinkram/backend-common/audit/file-audit.service'; +import { ActionEntity } from '@kleinkram/backend-common/entities/action/action.entity'; +import { FileEntity } from '@kleinkram/backend-common/entities/file/file.entity'; +import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; +import { + IStorageBucket, + StorageItem, +} from '@kleinkram/backend-common/modules/storage/types'; +import { FileEventType, FileState } from '@kleinkram/shared'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import logger from '../logger'; + +@Injectable() +export class FileStorageService { + constructor( + @InjectRepository(FileEntity) + private fileRepository: Repository, + @Inject('DataStorageBucket') + private readonly dataStorage: IStorageBucket, + private readonly auditService: FileAuditService, + ) {} + + async generateDownload( + uuid: string, + expires: boolean, + // eslint-disable-next-line @typescript-eslint/naming-convention + preview_only: boolean, + actor?: UserEntity, + action?: ActionEntity, + ): Promise { + // verify that an uuid is provided + if (!uuid || uuid === '') + throw new BadRequestException('UUID is required'); + + const file = await this.fileRepository.findOneOrFail({ + where: { uuid }, + relations: ['mission'], + }); + + // verify that the file exists in DB + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (file.uuid === undefined || file.uuid !== uuid) + throw new BadRequestException('File not found'); + + const stats = await this.dataStorage.getFileInfo(file.uuid); + + // verify that the file exists in storage + if (!stats) throw new NotFoundException('File not found'); + + // TODO: find a better solution to avoid leaking download links without logging + // we use that to preview the messages without spamming the audit log + if (!preview_only) { + await this.auditService.log( + FileEventType.DOWNLOADED, + { + fileUuid: file.uuid, + filename: file.filename, + missionUuid: file.mission?.uuid ?? '', + details: { expiresIn: expires ? '4 hours' : '1 week' }, + ...(actor ? { actor } : {}), + ...(action ? { action } : {}), + }, + true, + ); + } + + const disposition = preview_only + ? undefined + : { + 'response-content-disposition': `attachment; filename="${file.filename}"`, + }; + + return await this.dataStorage.getPresignedDownloadUrl( + file.uuid, + expires ? 4 * 60 * 60 : 604_800, + disposition, + ); + } + + async getStorage(): Promise { + const metrics = await this.dataStorage.getSystemMetrics?.(); + if (!metrics) { + return { + usedBytes: 0, + totalBytes: 0, + usedInodes: 0, + totalInodes: 0, + }; + } + + return metrics; + } + + async renameTags(): Promise { + const filesList = await this.dataStorage.listFiles(); + + await Promise.all( + filesList.map(async (file: StorageItem): Promise => { + if (!file.name) { + logger.debug(`Filename is empty: ${JSON.stringify(file)}`); + return; + } + const fileEntity = await this.fileRepository.findOne({ + where: { uuid: file.name }, + relations: ['mission', 'mission.project'], + }); + if (fileEntity === null) { + logger.error(`File ${file.name} not found in database`); + return; + } + + await this.dataStorage.removeTags(file.name); + + if (fileEntity.mission === undefined) + throw new Error('Mission not found!'); + if (fileEntity.mission.project === undefined) + throw new Error('Project not found!'); + + await this.dataStorage.addTags(file.name, { + projectUuid: fileEntity.mission.project.uuid, + missionUuid: fileEntity.mission.uuid, + filename: fileEntity.filename, + }); + }), + ).catch((error: unknown) => { + logger.error(error); + }); + } + + async recomputeFileSizes(): Promise { + const files = await this.fileRepository.find({ + where: { + state: In([FileState.OK, FileState.FOUND]), + }, + }); + await Promise.all( + files.map(async (file) => { + const stats = await this.dataStorage.getFileInfo(file.uuid); + + if (stats) { + file.size = stats.size; + logger.debug( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + `Updated size for ${file.filename}: ${file.size?.toString()}`, + ); + } else { + logger.error( + `File ${file.uuid} not found in storage, setting state to LOST`, + ); + file.state = FileState.LOST; + } + await this.fileRepository.save(file); + }), + ); + } +} diff --git a/backend/src/services/file.service.ts b/backend/src/services/file.service.ts deleted file mode 100644 index c5c931684..000000000 --- a/backend/src/services/file.service.ts +++ /dev/null @@ -1,1632 +0,0 @@ -import { fileEntityToDto, fileEntityToDtoWithTopic } from '@/serialization'; -import { - FileEventsDto, - FileExistsResponseDto, - FilesDto, - FileWithTopicDto, - SortOrder, - StorageOverviewDto, - TemporaryFileAccessesDto, - UpdateFile, -} from '@kleinkram/api-dto'; -import { FileAuditService } from '@kleinkram/backend-common/audit/file-audit.service'; -import { redis } from '@kleinkram/backend-common/consts'; -import { ActionEntity } from '@kleinkram/backend-common/entities/action/action.entity'; -import { CategoryEntity } from '@kleinkram/backend-common/entities/category/category.entity'; -import { FileEventEntity } from '@kleinkram/backend-common/entities/file/file-event.entity'; -import { FileEntity } from '@kleinkram/backend-common/entities/file/file.entity'; -import { IngestionJobEntity } from '@kleinkram/backend-common/entities/file/ingestion-job.entity'; -import { MissionEntity } from '@kleinkram/backend-common/entities/mission/mission.entity'; -import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity'; -import env from '@kleinkram/backend-common/environment'; -import { - DataType, - FileEventType, - FileOrigin, - FileState, - FileType, - HealthStatus, - TriggerEvent, - UserRole, -} from '@kleinkram/shared'; -import { - BadRequestException, - ConflictException, - Inject, - Injectable, - NotFoundException, - OnModuleInit, - UnsupportedMediaTypeException, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { - Brackets, - DataSource, - In, - MoreThan, - QueryFailedError, - Repository, - SelectQueryBuilder, -} from 'typeorm'; -import { - addFileFilters, - addMissionFilters, - addProjectFilters, - addSort, - convertGlobToLikePattern, -} from './utilities'; - -import { TriggerService } from '@/services/trigger.service'; - -import { - addAccessConstraints, - addAccessConstraintsToFileQuery, - addAccessConstraintsToMissionQuery, - addAccessConstraintsToProjectQuery, -} from '@/endpoints/auth/auth-helper'; -import { TagTypeEntity } from '@kleinkram/backend-common/entities/tagType/tag-type.entity'; -import { UserEntity } from '@kleinkram/backend-common/entities/user/user.entity'; -import { - IStorageBucket, - StorageCredentials, - StorageItem, -} from '@kleinkram/backend-common/modules/storage/types'; -import Queue from 'bull'; -import logger from '../logger'; - -const FIND_MANY_SORT_KEYS = { - name: 'file.filename', - filename: 'file.filename', - createdAt: 'file.createdAt', - updatedAt: 'file.updatedAt', - creator: 'user.name', - size: 'file.size', - state: 'file.state', - date: 'file.date', - - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.filename': 'file.filename', - - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.createdAt': 'file.createdAt', - - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.updatedAt': 'file.updatedAt', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.size': 'file.size', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.state': 'file.state', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'file.date': 'file.date', -}; - -const FILE_EXTENSION_TO_FILE_TYPE_MAP: ReadonlyMap = new Map([ - ['.bag', FileType.BAG], - ['.mcap', FileType.MCAP], - ['.yaml', FileType.YAML], - ['.yml', FileType.YAML], - ['.svo2', FileType.SVO2], - ['.tum', FileType.TUM], - ['.db3', FileType.DB3], -]); - -@Injectable() -export class FileService implements OnModuleInit { - private fileCleanupQueue!: Queue.Queue; - - constructor( - @InjectRepository(FileEntity) - private fileRepository: Repository, - @InjectRepository(MissionEntity) - private missionRepository: Repository, - @InjectRepository(ProjectEntity) - private projectRepository: Repository, - @InjectRepository(UserEntity) - private userRepository: Repository, - private readonly dataSource: DataSource, - @InjectRepository(TagTypeEntity) - private tagTypeRepository: Repository, - @InjectRepository(CategoryEntity) - private categoryRepository: Repository, - @Inject('DataStorageBucket') - private readonly dataStorage: IStorageBucket, - @InjectRepository(FileEventEntity) - private eventRepo: Repository, - private readonly auditService: FileAuditService, - private readonly triggerService: TriggerService, - ) {} - - onModuleInit(): void { - this.fileCleanupQueue = new Queue('file-cleanup', { - redis, - }); - } - - async findMany( - projectUuids: string[], - projectPatterns: string[], - missionUuids: string[], - missionPatterns: string[], - fileUuids: string[], - filePatterns: string[], - missionMetadata: Record, - sortBy: string | undefined, - sortOrder: SortOrder, - take: number, - skip: number, - userUuid: string, - ): Promise { - let query = this.fileRepository - .createQueryBuilder('file') - .leftJoinAndSelect('file.mission', 'mission') - .leftJoinAndSelect('mission.project', 'project') - .leftJoinAndSelect('file.creator', 'creator'); - - query = addAccessConstraintsToFileQuery(query, userUuid); - - query = addProjectFilters( - query, - this.projectRepository, - projectUuids, - projectPatterns, - ); - - query = addMissionFilters( - query, - this.missionRepository, - missionUuids, - missionPatterns, - missionMetadata, - ); - - query = addFileFilters( - query, - this.fileRepository, - fileUuids, - filePatterns, - ); - - if (sortBy !== undefined) { - query = addSort(query, FIND_MANY_SORT_KEYS, sortBy, sortOrder); - } - - const [files, count] = await query - .take(take) - .skip(skip) - .getManyAndCount(); - - return { - data: files.map((element) => fileEntityToDto(element)), - count, - take, - skip, - }; - } - - /** - * Checks if the user has access to the specified missions and projects. - * - * This method is NOT intended for fine-grained access control, but rather - * to produce nice error messages when a user tries to access resources they - * should not. - */ - async checkResourceAccess( - projectUuids: string[], - missionUuids: string[], - userUuid: string, - ): Promise { - // Verify Projects - if (projectUuids.length > 0) { - const uniqueProjectUuids = [...new Set(projectUuids)]; - - let query = this.projectRepository.createQueryBuilder('project'); - - // Filter by the specific requested IDs - query.where('project.uuid IN (:...uuids)', { - uuids: uniqueProjectUuids, - }); - - // Apply standard security constraints (Admins see all; Users see their own) - query = addAccessConstraintsToProjectQuery(query, userUuid); - - // Fetch allowed IDs - const foundProjects = await query.select('project.uuid').getMany(); - const foundUuids = new Set(foundProjects.map((p) => p.uuid)); - - // Calculate missing - const missing = uniqueProjectUuids.filter( - (id) => !foundUuids.has(id), - ); - - if (missing.length > 0) { - throw new NotFoundException( - `The following Project UUIDs do not exist or you do not have access: ${missing.join(', ')}`, - ); - } - } - - // Verify Missions - if (missionUuids.length > 0) { - const uniqueMissionUuids = [...new Set(missionUuids)]; - - let query = this.missionRepository - .createQueryBuilder('mission') - .select('mission.uuid') - .leftJoin('mission.project', 'project'); - - // Filter by the specific requested IDs - query.where('mission.uuid IN (:...uuids)', { - uuids: uniqueMissionUuids, - }); - - // Apply standard security constraints - query = addAccessConstraintsToMissionQuery(query, userUuid); - - // Fetch allowed IDs - const foundMissions = await query.select('mission.uuid').getMany(); - const foundUuids = new Set(foundMissions.map((m) => m.uuid)); - - // Calculate missing - const missing = uniqueMissionUuids.filter( - (id) => !foundUuids.has(id), - ); - - if (missing.length > 0) { - throw new NotFoundException( - `The following Mission UUIDs do not exist or you do not have access: ${missing.join(', ')}`, - ); - } - } - } - - /** - * - * Checks if the user has access to the specified missions and projects by name patterns. - * This method supports exact matches as well as wildcard patterns. - * - * This method is NOT intended for fine-grained access control, but rather - * to produce nice error messages when a user tries to access resources they - * should not. - * - */ - async checkResourceAccessByName( - projectNamePatterns: string[], - missionNamePatterns: string[], - userUuid: string, - exactMatch = false, - ): Promise { - logger.debug( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Checking resource access by name for user ${userUuid}. Projects: ${projectNamePatterns}, Missions: ${missionNamePatterns}, Exact: ${exactMatch}`, - ); - - // We use addProjectFilters which supports exactMatch natively - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (projectNamePatterns && projectNamePatterns.length > 0) { - const missingPatterns: string[] = []; - - for (const pattern of projectNamePatterns) { - let query = - this.projectRepository.createQueryBuilder('project'); - - query = addAccessConstraintsToProjectQuery(query, userUuid); - query = addProjectFilters( - query, - this.projectRepository, - [], - [pattern], - exactMatch, - ); - - const count = await query.getCount(); - if (count === 0) { - missingPatterns.push(pattern); - } - } - - if (missingPatterns.length > 0) { - throw new NotFoundException( - `The following Project patterns matched no accessible resources: ${missingPatterns.join(', ')}`, - ); - } - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (missionNamePatterns && missionNamePatterns.length > 0) { - const missingPatterns: string[] = []; - - for (const pattern of missionNamePatterns) { - let query = this.missionRepository - .createQueryBuilder('mission') - .leftJoin('mission.project', 'project'); - - query = addAccessConstraintsToMissionQuery(query, userUuid); - - if (exactMatch) { - query.andWhere('LOWER(mission.name) = LOWER(:pattern)', { - pattern, - }); - } else { - const likePattern = convertGlobToLikePattern(pattern); - // Use standard wildcard match (case-insensitive) - query.andWhere('LOWER(mission.name) LIKE :pattern', { - pattern: `%${likePattern.toLowerCase()}%`, - }); - } - - const count = await query.getCount(); - if (count === 0) { - missingPatterns.push(pattern); - } - } - - if (missingPatterns.length > 0) { - throw new NotFoundException( - `The following Mission patterns matched no accessible resources: ${missingPatterns.join(', ')}`, - ); - } - } - } - - /** - * Finds and paginates files based on a comprehensive set of filters. - * - * This method uses a two-query approach to correctly handle pagination with - * complex joins and groupings (required for 'matchAll' topics and tags): - * 1. The first query applies all filters and retrieves *only* the UUIDs - * of the matching files for the requested page, along with the *total count* - * of all matching files (pre-pagination). - * 2. The second query fetches the full file entities (with relations) for - * the UUIDs retrieved in the first query. - */ - async findFiltered( - fileName: string, - projectUUID: string, - missionUUID: string, - startDate: Date | undefined, - endDate: Date | undefined, - topics: string, - messageDatatype: string, - categories: string, - matchAllTopics: boolean, - fileTypes: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: Record, - userUUID: string, - take: number, - skip: number, - sort: string, - sortOrder: 'ASC' | 'DESC', - health: HealthStatus, - ): Promise { - const user = await this.userRepository.findOneOrFail({ - where: { uuid: userUUID }, - }); - - // Start building the query to fetch *only* IDs - let idQuery = this.fileRepository - .createQueryBuilder('file') - .select('file.uuid') // Select only the UUID - .leftJoin('file.mission', 'mission') - .leftJoin('mission.project', 'project') - .leftJoin('file.topics', 'topic'); // Joined for filtering - - // ADMIN users see all, others are constrained - if (user.role !== UserRole.ADMIN) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - idQuery = addAccessConstraints(idQuery, userUUID); - } - - // Apply simple filters - if (fileName) { - logger.debug(`Filtering files by filename: ${fileName}`); - const tokens = fileName.trim().split(/\s+/); - - if (tokens.length > 0) { - idQuery.andWhere( - new Brackets((qb) => { - for (const [index, token] of tokens.entries()) { - qb.andWhere( - `file.filename ILIKE :fileName_${String(index)}`, - { - [`fileName_${String(index)}`]: `%${token}%`, - }, - ); - } - }), - ); - } - } - - if (projectUUID) { - logger.debug(`Filtering files by projectUUID: ${projectUUID}`); - idQuery.andWhere('project.uuid = :projectUUID', { projectUUID }); - } - - if (missionUUID) { - logger.debug(`Filtering files by missionUUID: ${missionUUID}`); - idQuery.andWhere('mission.uuid = :missionUUID', { missionUUID }); - } - - if (startDate) { - logger.debug( - `Filtering files by start date: ${startDate.toString()}`, - ); - idQuery.andWhere('file.date >= :startDate', { startDate }); - } - - if (endDate) { - logger.debug(`Filtering files by end date: ${endDate.toString()}`); - idQuery.andWhere('file.date <= :endDate', { endDate }); - } - - // Apply complex filters via helper methods - this._applyFileTypeFilter(idQuery, fileTypes); - this._applyTopicFilter(idQuery, topics, matchAllTopics); - this._applyMessageDatatypeFilter(idQuery, messageDatatype); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (health) { - logger.debug(`Filtering files by health: ${health}`); - switch (health) { - case HealthStatus.HEALTHY: { - idQuery.andWhere('file.state IN (:...healthyStates)', { - healthyStates: [FileState.OK, FileState.FOUND], - }); - break; - } - case HealthStatus.UNHEALTHY: { - idQuery.andWhere('file.state IN (:...unhealthyStates)', { - unhealthyStates: [ - FileState.ERROR, - FileState.CONVERSION_ERROR, - FileState.LOST, - FileState.CORRUPTED, - ], - }); - break; - } - case HealthStatus.UPLOADING: { - idQuery.andWhere('file.state = :uploadingState', { - uploadingState: FileState.UPLOADING, - }); - break; - } - } - } - - const categoryUUIDs = categories ? categories.split(',') : []; - if (categoryUUIDs.length > 0) { - logger.debug(`Filtering files by categories: ${categories}`); - idQuery - .innerJoin('file.categories', 'category') - .andWhere('category.uuid IN (:...categoryUUIDs)', { - categoryUUIDs, - }); - } - - // The tag filter is async, so it must be awaited - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (tags && Object.keys(tags).length > 0) { - await this._applyTagFilter(idQuery, tags); - } - - // Group by file.uuid to deduplicate results from joins - // and allow 'HAVING' clauses for topics and tags - idQuery.groupBy('file.uuid'); - - const order = sortOrder === 'ASC' ? SortOrder.ASC : SortOrder.DESC; - - idQuery = addSort(idQuery, FIND_MANY_SORT_KEYS, sort, order); - idQuery.offset(skip).limit(take); - - const [fileIdObjects, count] = await idQuery.getManyAndCount(); - - if (fileIdObjects.length === 0) { - logger.silly('No files found'); - return { - count, - data: [], - take, - skip, - }; - } - - const fileIds = fileIdObjects.map((file) => file.uuid); - - // It must re-apply joins (for selection) and sorting. - let filesQuery = this.fileRepository - .createQueryBuilder('file') - .leftJoinAndSelect('file.mission', 'mission') - .leftJoinAndSelect('mission.project', 'project') - .leftJoinAndSelect('file.topics', 'topic') - .leftJoinAndSelect('file.creator', 'creator') - .leftJoinAndSelect('file.categories', 'category') - .where('file.uuid IN (:...fileIds)', { fileIds }); - - filesQuery = addSort(filesQuery, FIND_MANY_SORT_KEYS, sort, order); - - const files = await filesQuery.getMany(); - - return { - count, - data: files.map((element) => fileEntityToDto(element)), - take, - skip, - }; - } - - /** - * Applies file type filtering to the query. - */ - private _applyFileTypeFilter( - query: SelectQueryBuilder, - fileTypes: string, - ): void { - if (!fileTypes) { - return; - } - - const requestedTypes = fileTypes.split(','); - const requestedTypesUpper = requestedTypes.map((t) => t.toUpperCase()); - - // If 'ALL' is requested, do nothing (apply no filter) - if (requestedTypesUpper.includes(FileType.ALL)) { - return; - } - - // Build a lookup map of valid enum values (e.g., "mcap" -> "MCAP") - const validTypesLookup = new Map(); - for (const type of Object.values(FileType).filter( - (_type) => _type !== FileType.ALL, - )) { - validTypesLookup.set(type.toLowerCase(), type); - } - // Manually add 'yml' to map to YAML since we merged them - validTypesLookup.set('yml', FileType.YAML); - - // Map requested types to their valid, cased enum values and deduplicate - const typesToFilter = [ - ...new Set( - requestedTypes - .map((requestType) => - validTypesLookup.get(requestType.toLowerCase()), - ) - .filter((type): type is string => !!type), // Filter out undefined - ), - ]; - - if (typesToFilter.length > 0) { - logger.debug( - `Filtering files by types: ${typesToFilter.join(',')}`, - ); - query.andWhere('file.type IN (:...fileTypes)', { - fileTypes: typesToFilter, - }); - } else { - // No valid types were provided (e.g., "garbage,foo") - logger.warn(`No valid file types found in filter: ${fileTypes}`); - - query.andWhere('1 = 0'); // Force query to return no results - } - } - - /** - * Applies topic filtering to the query. - */ - private _applyTopicFilter( - query: SelectQueryBuilder, - topics: string, - matchAllTopics: boolean, - ): void { - if (!topics) { - return; - } - - const splitTopics = topics.split(',').filter((t) => t.length > 0); - if (splitTopics.length === 0) { - return; - } - - // Filter files that have *at least one* of the topics - query.andWhere('topic.name IN (:...splitTopics)', { - splitTopics, - }); - - // If 'matchAllTopics' is true, add a HAVING clause - // to ensure the file has *all* requested topics. - if (matchAllTopics) { - query.having('COUNT(DISTINCT topic.name) = :topicCount', { - topicCount: splitTopics.length, - }); - } - } - - /** - * Applies message datatype filtering to the query. - */ - private _applyMessageDatatypeFilter( - query: SelectQueryBuilder, - messageDatatype: string, - ): void { - if (!messageDatatype) { - return; - } - - const splitMessageDatatype = messageDatatype - .split(',') - .filter((t) => t.length > 0); - if (splitMessageDatatype.length === 0) { - return; - } - - // Filter files that have *at least one* of the message datatypes - query.andWhere('topic.type IN (:...splitMessageDatatype)', { - splitMessageDatatype, - }); - } - - /** - * Applies tag filtering to the query. - * This is the most complex filter, requiring a 'relational division' query. - * - * We find files where the mission has tags that match ALL specified conditions. - * We do this by: - * 1. Joining mission tags and tag types. - * 2. Adding a WHERE clause: `(condition 1) OR (condition 2) OR ...` - * 3. Adding a HAVING clause: `COUNT(DISTINCT matched_tag_types) = total_conditions` - */ - private async _applyTagFilter( - query: SelectQueryBuilder, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: Record, - ): Promise { - const tagTypeUUIDs = Object.keys(tags); - if (tagTypeUUIDs.length === 0) { - return; - } - - const tagTypes = await this.tagTypeRepository.find({ - where: { uuid: In(tagTypeUUIDs) }, - }); - const tagTypeMap = new Map(tagTypes.map((t) => [t.uuid, t])); - - // Add the necessary joins for tag filtering - query - .leftJoin('mission.tags', 'tag') - .leftJoin('tag.tagType', 'tagtype'); - - const tagWhereClauses: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tagParameters: Record = {}; - const validTagNames = new Set(); - let validTagCount = 0; - - for (const uuid of tagTypeUUIDs) { - const tagtype = tagTypeMap.get(uuid); - if (!tagtype) { - logger.warn(`Invalid tag type UUID in filter: ${uuid}`); - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const value = tags[uuid]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const [column, processedValue] = this._getTagColumnAndValue( - tagtype.datatype, - value, - ); - - if (!column) { - logger.warn(`Unknown data type for tag type ${uuid}`); - continue; - } - - // Create unique parameter names for this condition - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const uuidParameter = `tagtype${validTagCount}`; - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const valueParameter = `tagval${validTagCount}`; - - // Build the clause: (tagtype.uuid = :uuid AND tag.VALUE_COLUMN = :value) - tagWhereClauses.push( - `(tagtype.uuid = :${uuidParameter} AND tag.${column} = :${valueParameter})`, - ); - tagParameters[uuidParameter] = uuid; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - tagParameters[valueParameter] = processedValue; - - validTagCount++; - validTagNames.add(tagtype.name); - } - - if (validTagCount === 0) { - // All provided tag filters were invalid - query.andWhere('1 = 0'); // Return no results - return; - } - - query.andWhere( - new Brackets((qb) => { - for (const clause of tagWhereClauses) qb.orWhere(clause); - }), - tagParameters, - ); - - query.having('COUNT(DISTINCT tagtype.name) = :tagCount', { - tagCount: validTagNames.size, - }); - } - - /** - * Helper to get the correct database column name based on - * the tag's DataType. - * - * We store tag values in different columns based on their type in - * order to support complex queries and indexing. - * - */ - private _getTagColumnAndValue( - dataType: DataType, - value: T, - ): [string | null, T | undefined] { - switch (dataType) { - case DataType.BOOLEAN: { - return ['BOOLEAN', value]; - } - case DataType.DATE: { - return ['DATE', value]; - } - case DataType.LOCATION: { - return ['LOCATION', value]; - } - case DataType.NUMBER: { - return ['NUMBER', value]; - } - case DataType.STRING: - case DataType.LINK: { - return ['STRING', value]; - } - default: { - return [null, undefined]; - } - } - } - async findOne(uuid: string): Promise { - const file = await this.fileRepository.findOneOrFail({ - where: { uuid }, - relations: [ - 'mission', - 'topics', - 'mission.project', - 'creator', - 'categories', - 'parent', - 'parent.topics', - 'derivedFiles', - 'derivedFiles.topics', - ], - }); - - return fileEntityToDtoWithTopic(file); - } - - async getFileEvents(fileUuid: string): Promise { - const events = await this.eventRepo.find({ - where: { - file: { uuid: fileUuid }, - }, - relations: ['actor', 'action', 'action.template', 'action.creator'], - order: { createdAt: 'DESC' }, - }); - - return { - count: events.length, - data: - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - events.map((event) => ({ - uuid: event.uuid, - type: event.type, - createdAt: event.createdAt, - details: event.details, - actor: event.actor - ? { - uuid: event.actor.uuid, - name: event.actor.name, - avatarUrl: null, - email: null, - } - : undefined, - action: event.action - ? { - uuid: event.action.uuid, - - name: event.action.template?.name, - - creator: event.action.creator - ? { - uuid: event.action.creator.uuid, - - name: event.action.creator.name, - avatarUrl: null, - email: null, - } - : undefined, - } - : undefined, - })) ?? [], - } as FileEventsDto; - } - - async getActionFileEvents(actionUuid: string): Promise { - const events = await this.eventRepo.find({ - where: { - action: { uuid: actionUuid }, - }, - relations: [ - 'actor', - 'action', - 'action.template', - 'file', - 'file.mission', - 'file.mission.project', - ], - order: { createdAt: 'DESC' }, - }); - - return { - count: events.length, - data: - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - events.map((event) => ({ - uuid: event.uuid, - type: event.type, - createdAt: event.createdAt, - details: event.details, - actor: event.actor - ? { - uuid: event.actor.uuid, - name: event.actor.name, - avatarUrl: null, - email: null, - } - : undefined, - action: event.action - ? { - uuid: event.action.uuid, - - name: event.action.template?.name, - } - : undefined, - file: event.file - ? { - uuid: event.file.uuid, - filename: event.file.filename, - missionUuid: event.file.mission?.uuid ?? '', - missionName: event.file.mission?.name ?? '', - projectUuid: - event.file.mission?.project?.uuid ?? '', - projectName: - event.file.mission?.project?.name ?? '', - } - : undefined, - })) ?? [], - } as FileEventsDto; - } - - /** - * Updates a file with the given uuid. - * @param uuid - * @param file - */ - /** - * Updates a file with the given uuid. - */ - async update( - uuid: string, - file: UpdateFile, - actor?: UserEntity, - action?: ActionEntity, - ): Promise { - logger.debug(`Updating file with uuid: ${uuid}`); - - const databaseFile = await this.fileRepository.findOneOrFail({ - where: { uuid }, - relations: { mission: { project: true } }, - }); - - if (!databaseFile.mission) throw new Error('Mission not found!'); - if (!databaseFile.mission.project) - throw new Error('Project not found!'); - - const oldFilename = databaseFile.filename; - const isRenamed = file.filename !== oldFilename; - - // validate file ending - const validExtensions = [...FILE_EXTENSION_TO_FILE_TYPE_MAP.entries()] - .filter(([, type]) => type === databaseFile.type) - .map(([extension]) => extension); - - if ( - !validExtensions.some((extension) => - file.filename.endsWith(extension), - ) - ) { - throw new BadRequestException( - `File ending must be one of: ${validExtensions.join(', ')}`, - ); - } - - databaseFile.filename = file.filename; - databaseFile.date = file.date; - - // Handle Mission Move via Update - let oldMissionUuid: string | undefined; - if ( - file.missionUuid && - file.missionUuid !== databaseFile.mission.uuid - ) { - oldMissionUuid = databaseFile.mission.uuid; - const newMission = await this.missionRepository.findOneOrFail({ - where: { uuid: file.missionUuid }, - relations: ['project'], - }); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (newMission) databaseFile.mission = newMission; - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (file.categories) { - databaseFile.categories = await this.categoryRepository.find({ - where: { uuid: In(file.categories) }, - }); - } - - await this.dataSource - .transaction(async (transactionalEntityManager) => { - // [Existing Transaction Logic] - await transactionalEntityManager.save(FileEntity, databaseFile); - }) - .catch((error: unknown) => { - // [Existing Error Handling] - throw error; - }); - - // Log Rename Event - if (isRenamed) { - await this.auditService.log( - FileEventType.RENAMED, - { - fileUuid: databaseFile.uuid, - filename: databaseFile.filename, - missionUuid: databaseFile.mission.uuid, - ...(actor ? { actor } : {}), - ...(action ? { action } : {}), - details: { oldFilename, newFilename: file.filename }, - }, - true, - ); - await this.triggerService.addFileEvent( - databaseFile.uuid, - TriggerEvent.RENAME, - ); - } - - // Log Move Event (if done via update) - if (oldMissionUuid) { - await this.auditService.log( - FileEventType.MOVED, - { - fileUuid: databaseFile.uuid, - filename: databaseFile.filename, - missionUuid: databaseFile.mission.uuid, - ...(actor ? { actor } : {}), - details: { - fromMission: oldMissionUuid, - toMission: file.missionUuid, - }, - }, - true, - ); - await this.triggerService.addFileEvent( - databaseFile.uuid, - TriggerEvent.MOVE, - ); - } - - await this.dataStorage.addTags(databaseFile.uuid, { - // @ts-expect-error - projectUuid: databaseFile.mission.project.uuid, - missionUuid: databaseFile.mission.uuid, - filename: databaseFile.filename, - }); - return this.fileRepository.findOne({ - where: { uuid }, - relations: ['mission', 'mission.project'], - }); - } - - /** - * Generate a download link for a file with the given uuid. - * The link will expire after 1 week if expires is set to true. - * - // eslint-disable-next-line @typescript-eslint/naming-convention - * @param uuid The unique identifier of the file - * @param expires Whether the download link should expire - * @param preview_only - * @param actor - * @param action - */ - async generateDownload( - uuid: string, - expires: boolean, - // eslint-disable-next-line @typescript-eslint/naming-convention - preview_only: boolean, - actor?: UserEntity, - action?: ActionEntity, - ): Promise { - // verify that an uuid is provided - if (!uuid || uuid === '') - throw new BadRequestException('UUID is required'); - - const file = await this.fileRepository.findOneOrFail({ - where: { uuid }, - relations: ['mission'], - }); - - // verify that the file exists in DB - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (file.uuid === undefined || file.uuid !== uuid) - throw new BadRequestException('File not found'); - - const stats = await this.dataStorage.getFileInfo(file.uuid); - - // verify that the file exists in storage - if (!stats) throw new NotFoundException('File not found'); - - // TODO: find a better solution to avoid leaking download links without logging - // we use that to preview the messages without spamming the audit log - if (!preview_only) { - await this.auditService.log( - FileEventType.DOWNLOADED, - { - fileUuid: file.uuid, - filename: file.filename, - missionUuid: file.mission?.uuid ?? '', - details: { expiresIn: expires ? '4 hours' : '1 week' }, - ...(actor ? { actor } : {}), - ...(action ? { action } : {}), - }, - true, - ); - } - - const disposition = preview_only - ? undefined - : { - 'response-content-disposition': `attachment; filename="${file.filename}"`, - }; - - return await this.dataStorage.getPresignedDownloadUrl( - file.uuid, - expires ? 4 * 60 * 60 : 604_800, - disposition, - ); - } - - async findOneByName( - missionUUID: string, - name: string, - ): Promise { - return this.fileRepository.findOne({ - where: { mission: { uuid: missionUUID }, filename: name }, - relations: ['creator'], - }); - } - - async moveFiles( - fileUUIDs: string[], - missionUUID: string, - actor?: UserEntity, - action?: ActionEntity, - ): Promise { - await Promise.all( - fileUUIDs.map(async (uuid) => { - try { - const file = await this.fileRepository.findOneOrFail({ - where: { uuid }, - relations: ['mission'], - }); - - const oldMissionUuid = file.mission?.uuid; - - file.mission = { uuid: missionUUID } as MissionEntity; - await this.fileRepository.save(file); - - // Log Move Event - await this.auditService.log( - FileEventType.MOVED, - { - fileUuid: uuid, - filename: file.filename, - missionUuid: missionUUID, - ...(actor ? { actor } : {}), - ...(action ? { action } : {}), - details: { - fromMission: oldMissionUuid, - toMission: missionUUID, - }, - }, - true, - ); - await this.triggerService.addFileEvent( - uuid, - TriggerEvent.MOVE, - ); - - // ... [Existing Tag Update Logic] ... - const newFile = await this.fileRepository.findOneOrFail({ - where: { uuid }, - relations: ['mission', 'mission.project'], - }); - await this.dataStorage.addTags(file.uuid, { - filename: file.filename, - missionUuid: missionUUID, - projectUuid: newFile.mission?.project?.uuid ?? '', - }); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.error(`Error moving file ${uuid}: ${error}`); - } - }), - ); - } - - /** - * Delete a file with the given uuid. - * The file will be removed from the database and from storage. - * - * @param uuid The unique identifier of the file - * @param actor - * @param action - */ - async deleteFile( - uuid: string, - actor?: UserEntity, - action?: ActionEntity, - ): Promise { - if (!uuid) throw new BadRequestException('UUID is required'); - - logger.debug(`Deleting file with uuid: ${uuid}`); - - const file = await this.fileRepository.findOne({ - where: { uuid }, - relations: ['mission'], - }); - - if (file) { - await this.auditService.log( - FileEventType.DELETED, - { - fileUuid: uuid, - filename: file.filename, - missionUuid: file.mission?.uuid ?? '', - ...(actor ? { actor } : {}), - ...(action ? { action } : {}), - details: { snapshot: 'File deleted from DB and Storage' }, - }, - true, - ); - } - - await this.fileRepository.manager.transaction( - async (transactionalEntityManager) => { - // [Existing Deletion Logic] - const fileToDelete = - await transactionalEntityManager.findOneOrFail(FileEntity, { - where: { uuid }, - }); - await this.dataStorage - .deleteFile(fileToDelete.uuid) - .catch(() => { - logger.error( - `File ${fileToDelete.uuid} not found in storage, deleting from database only!`, - ); - }); - - await transactionalEntityManager.softRemove(fileToDelete); - }, - ); - - logger.debug(`File with uuid ${uuid} deleted`); - } - - async getStorage(): Promise { - const metrics = await this.dataStorage.getSystemMetrics?.(); - if (!metrics) { - return { - usedBytes: 0, - totalBytes: 0, - usedInodes: 0, - totalInodes: 0, - }; - } - - return metrics; - } - - async isUploading(userUUID: string): Promise { - return this.fileRepository - .findOne({ - where: { - state: FileState.UPLOADING, - createdAt: MoreThan( - new Date(Date.now() - 12 * 60 * 60 * 1000), - ), - creator: { uuid: userUUID }, - }, - }) - .then((r) => !!r); - } - - /** - * Get temporary access to upload files to storage. - * This function creates a new file entry in the database and a new queue entry. - * The queue entry is used to track the upload progress. - * - * The function returns a list of access credentials for each file. - * - * @param filenames list of filenames to upload - * @param missionUUID the mission to upload the files to - * @param userUUID the user that is uploading the files - * @param action - * @param uploadSource - */ - async getTemporaryAccess( - filenames: string[], - missionUUID: string, - userUUID: string, - action?: ActionEntity, - uploadSource = 'Web Interface', - ): Promise { - const mission = await this.missionRepository.findOneOrFail({ - where: { uuid: missionUUID }, - relations: ['project'], - }); - const user = await this.userRepository.findOneOrFail({ - where: { uuid: userUUID }, - }); - - return await this.dataSource.transaction(async (manager) => { - // Deduplicate filenames to avoid self-collisions - const uniqueFilenames = [...new Set(filenames)]; - const credentials: { - bucket: string | null; - fileName: string; - fileUUID: string | null; - accessCredentials: StorageCredentials | null; - error?: string | null; - }[] = []; - - const invalidFiles: { filename: string; error: string }[] = []; - - // Check for existing files first to avoid transaction abortion on duplicate key error - const existingFiles = await manager.find(FileEntity, { - where: { - filename: In(uniqueFilenames), - mission: { - uuid: missionUUID, - }, - }, - }); - - const existingFilenames = new Set( - existingFiles.map((f) => f.filename), - ); - - for (const filename of uniqueFilenames) { - const emptyCredentials: { - bucket: string | null; - fileName: string; - fileUUID: string | null; - accessCredentials: StorageCredentials | null; - error: string | null; - queueUUID?: string; - } = { - bucket: null, - fileName: filename, - - fileUUID: null, - - accessCredentials: null, - - error: null, - }; - - // eslint-disable-next-line @typescript-eslint/naming-convention - const supported_file_endings = [ - ...FILE_EXTENSION_TO_FILE_TYPE_MAP.keys(), - ]; - - if ( - !supported_file_endings.some((ending) => - filename.endsWith(ending), - ) - ) { - emptyCredentials.error = 'Invalid file ending'; - credentials.push(emptyCredentials); - continue; - } - - const matchingFileType = supported_file_endings.find((ending) => - filename.endsWith(ending), - ); - if (matchingFileType === undefined) - throw new UnsupportedMediaTypeException(); - const fileType: FileType | undefined = - FILE_EXTENSION_TO_FILE_TYPE_MAP.get(matchingFileType); - if (fileType === undefined) - throw new UnsupportedMediaTypeException(); - - if (existingFilenames.has(filename)) { - invalidFiles.push({ - filename, - error: 'File already exists', - }); - continue; - } - - try { - // Use a nested transaction (savepoint) for each file - await manager.transaction(async (nestedManager) => { - const file = await nestedManager.save( - FileEntity, - nestedManager.create(FileEntity, { - date: new Date(), - size: 0, - filename, - mission, - creator: user, - type: fileType, - state: FileState.UPLOADING, - origin: FileOrigin.UPLOAD, - }), - ); - - await this.auditService.log( - FileEventType.UPLOAD_STARTED, - { - fileUuid: file.uuid, - filename: file.filename, - missionUuid: missionUUID, - actor: user, - ...(action ? { action } : {}), - details: { - origin: FileOrigin.UPLOAD, - source: uploadSource, - }, - }, - true, - ); - - credentials.push({ - bucket: env.S3_DATA_BUCKET_NAME, - fileUUID: file.uuid, - fileName: filename, - accessCredentials: - await this.dataStorage.generateTemporaryCredential( - file.uuid, - ), - }); - - // Add to local set to catch duplicates in the same batch - existingFilenames.add(filename); - }); - } catch (error: unknown) { - if ( - error instanceof QueryFailedError && - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - error.driverError.code === '23505' - ) { - invalidFiles.push({ - filename, - error: 'File already exists', - }); - // Also add to set so we don't try again if it appears again in list (though deduplication handles this) - existingFilenames.add(filename); - } else { - throw error; - } - } - } - - if (invalidFiles.length > 0) { - logger.warn( - `getTemporaryAccess: user="${userUUID}" mission="${missionUUID}" ` + - `denied upload for ${invalidFiles.length.toString()} already-existing file(s): ` + - invalidFiles.map((f) => `"${f.filename}"`).join(', '), - ); - throw new ConflictException({ - message: 'Files already exist', - errors: invalidFiles, - }); - } - - return { - // TODO: fix typing - // @ts-ignore - data: credentials, - count: credentials.length, - skip: 0, - take: credentials.length, - }; - }); - } - - async cancelUpload( - uuids: string[], - missionUUID: string, - userUUID: string, - ): Promise { - // Cleanup cannot be done synchronously as this takes too long; the request is sent on unload; delaying the onUnload is difficult and discouraged - return this.fileCleanupQueue.add('cancelUpload', { - uuids, - missionUUID, - userUUID, - }); - } - - async deleteMultiple( - fileUUIDs: string[], - missionUUID: string, - ): Promise { - if (fileUUIDs.length === 0) return; - - const uniqueFilesUuids = [...new Set(fileUUIDs)]; - - await this.fileRepository.manager.transaction( - async (transactionalEntityManager) => { - const files = await transactionalEntityManager.find( - FileEntity, - { - where: { - uuid: In(uniqueFilesUuids), - mission: { uuid: missionUUID }, - }, - }, - ); - - const uniqueDatabaseFilesUuids = [ - ...new Set(files.map((f) => f.uuid)), - ]; - if ( - uniqueDatabaseFilesUuids.length !== uniqueFilesUuids.length - ) { - throw new NotFoundException( - 'Some files not found, aborting', - ); - } - - // Delete potentially running ingestion jobs - await transactionalEntityManager.softDelete( - IngestionJobEntity, - { - identifier: In(uniqueDatabaseFilesUuids), - }, - ); - - await Promise.all( - files.map(async (file) => { - await this.dataStorage - .deleteFile(file.uuid) - .catch(() => { - logger.error( - `File ${file.uuid} not found in storage, deleting from database only!`, - ); - }); - }), - ); - - await transactionalEntityManager.softDelete( - FileEntity, - uniqueDatabaseFilesUuids, - ); - }, - ); - } - - async exists(fileUUID: string): Promise { - return { - exists: await this.fileRepository.exists({ - where: { uuid: fileUUID }, - }), - uuid: fileUUID, - }; - } - - async renameTags(): Promise { - const filesList = await this.dataStorage.listFiles(); - - await Promise.all( - filesList.map(async (file: StorageItem): Promise => { - if (!file.name) { - logger.debug(`Filename is empty: ${JSON.stringify(file)}`); - return; - } - const fileEntity = await this.fileRepository.findOne({ - where: { uuid: file.name }, - relations: ['mission', 'mission.project'], - }); - if (fileEntity === null) { - logger.error(`File ${file.name} not found in database`); - return; - } - - await this.dataStorage.removeTags(file.name); - - if (fileEntity.mission === undefined) - throw new Error('Mission not found!'); - if (fileEntity.mission.project === undefined) - throw new Error('Project not found!'); - - await this.dataStorage.addTags(file.name, { - projectUuid: fileEntity.mission.project.uuid, - missionUuid: fileEntity.mission.uuid, - filename: fileEntity.filename, - }); - }), - ).catch((error: unknown) => { - logger.error(error); - }); - } - - async recomputeFileSizes(): Promise { - const files = await this.fileRepository.find({ - where: { - state: In([FileState.OK, FileState.FOUND]), - }, - }); - await Promise.all( - files.map(async (file) => { - const stats = await this.dataStorage.getFileInfo(file.uuid); - - if (stats) { - file.size = stats.size; - logger.debug( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - `Updated size for ${file.filename}: ${file.size?.toString()}`, - ); - } else { - logger.error( - `File ${file.uuid} not found in storage, setting state to LOST`, - ); - file.state = FileState.LOST; - } - await this.fileRepository.save(file); - }), - ); - } - - async reextractMissingTopics(): Promise { - const filesToFix = await this.fileRepository - .createQueryBuilder('file') - .leftJoin('file.topics', 'topic') - .where('file.type = :type', { type: FileType.BAG }) - .andWhere('file.state = :state', { state: FileState.OK }) - .andWhere('topic.uuid IS NULL') - .select(['file.uuid', 'file.filename']) - .getMany(); - - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.debug(`Found ${filesToFix.length} bag files missing topics.`); - - for (const file of filesToFix) { - await this.fileCleanupQueue.add('extract-topics-repair', { - fileUuid: file.uuid, - filename: file.filename, - }); - } - - return filesToFix.length; - } -} diff --git a/backend/src/services/tag.service.ts b/backend/src/services/metadata.service.ts similarity index 98% rename from backend/src/services/tag.service.ts rename to backend/src/services/metadata.service.ts index 099e33d2c..675747e94 100644 --- a/backend/src/services/tag.service.ts +++ b/backend/src/services/metadata.service.ts @@ -18,7 +18,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, ILike, Repository } from 'typeorm'; @Injectable() -export class TagService { +export class MetadataService { constructor( @InjectRepository(MetadataEntity) private tagRepository: Repository, @@ -164,7 +164,7 @@ export class TagService { } await this.tagRepository.save(tag); - return {}; + return { success: true }; } async updateTagType( @@ -261,11 +261,12 @@ export class TagService { missionUUID: string, tags: Record, ): Promise { - return Promise.all( + await Promise.all( Object.entries(tags).map(([tagTypeUUID, value]) => this.addTagType(missionUUID, tagTypeUUID, value), ), ); + return { success: true }; } async deleteTag(uuid: string): Promise { @@ -297,13 +298,13 @@ export class TagService { } async getFiltered( - name: string, + name: string | undefined, type: DataType | undefined, skip: number, take: number, ): Promise { const where: FindOptionsWhere = {}; - if (name !== '') { + if (name) { where.name = ILike(`%${name}%`); } if ( diff --git a/backend/src/services/mission.service.ts b/backend/src/services/mission.service.ts index c9a0205ac..b8d848a30 100644 --- a/backend/src/services/mission.service.ts +++ b/backend/src/services/mission.service.ts @@ -1,10 +1,6 @@ -import { - addAccessConstraints, - addAccessConstraintsToMissionQuery, -} from '@/endpoints/auth/auth-helper'; +import { addAccessConstraintsToMissionQuery } from '@/endpoints/auth/auth-helper'; import { AuthHeader } from '@/endpoints/auth/parameter-decorator'; import { - missionEntityToDtoWithCreator, missionEntityToDtoWithFiles, missionEntityToFlatDto, missionEntityToMinimumDto, @@ -13,6 +9,7 @@ import { CreateMission, FlatMissionDto, MinimumMissionsDto, + MissionQueryDto, MissionsDto, MissionWithFilesDto, } from '@kleinkram/api-dto'; @@ -25,7 +22,7 @@ import { ConflictException, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ILike, Not, Repository } from 'typeorm'; import logger from '../logger'; -import { TagService } from './tag.service'; +import { MetadataService } from './metadata.service'; import { UserService } from './user.service'; import { addFileStats, @@ -55,7 +52,7 @@ export class MissionService { @InjectRepository(UserEntity) private userRepository: Repository, private userService: UserService, - private tagService: TagService, + private metadataService: MetadataService, @Inject('DataStorageBucket') private readonly dataStorage: IStorageBucket, ) {} @@ -120,7 +117,7 @@ export class MissionService { await Promise.all( Object.entries(createMission.tags).map( async ([tagTypeUUID, value]) => { - return this.tagService.addTagType( + return this.metadataService.addTagType( newMission.uuid, tagTypeUUID, value, @@ -157,17 +154,13 @@ export class MissionService { } async findMany( - projectUuids: string[], - projectPatterns: string[], - missionUuids: string[], - missionPatterns: string[], - missionMetadata: Record, - sortBy: string | undefined, - sortOrder: SortOrder, - skip: number, - take: number, + query: MissionQueryDto, userUuid: string, - ): Promise { + ): Promise { + const user = await this.userRepository.findOneOrFail({ + where: { uuid: userUuid }, + }); + let idQuery = this.missionRepository .createQueryBuilder('mission') .select('mission.uuid') @@ -176,32 +169,59 @@ export class MissionService { .leftJoin('mission.tags', 'tag') .leftJoin('tag.tagType', 'tagType'); - idQuery = addAccessConstraintsToMissionQuery(idQuery, userUuid); + if (user.role !== UserRole.ADMIN) { + idQuery = addAccessConstraintsToMissionQuery(idQuery, userUuid); + } + + // Unify single-project filters with projectUuids + const projectUuid = query.projectUuid ?? query.uuid; + const projectUuids = + query.projectUuids ?? (projectUuid ? [projectUuid] : []); idQuery = addProjectFilters( idQuery, this.projectRepository, projectUuids, - projectPatterns, + query.projectPatterns ?? [], + query.exactMatch === 'true', ); idQuery = addMissionFilters( idQuery, this.missionRepository, - missionUuids, - missionPatterns, - missionMetadata, + query.missionUuids ?? [], + query.missionPatterns ?? [], + query.metadata ?? {}, ); - if (sortBy !== undefined) { - idQuery = addSort(idQuery, FIND_MANY_SORT_KEYS, sortBy, sortOrder); + if (query.search) { + const tokens = query.search.trim().split(/\s+/); + for (const [index, token] of tokens.entries()) { + idQuery.andWhere( + `mission.name ILIKE :search_${String(index)}`, + { + [`search_${String(index)}`]: `%${token}%`, + }, + ); + } + } + + const sortField = query.sortBy ?? 'createdAt'; + let order = query.sortOrder; + if (query.sortDirection) { + order = + query.sortDirection === 'DESC' ? SortOrder.DESC : SortOrder.ASC; } + idQuery = addSort(idQuery, FIND_MANY_SORT_KEYS, sortField, order); + // Get distinct mission UUIDs idQuery.groupBy('mission.uuid'); // Get count before pagination const count = await idQuery.getCount(); + const take = query.take; + const skip = query.skip; idQuery.take(take).skip(skip); const missionIds = await idQuery.getRawMany(); @@ -215,25 +235,50 @@ export class MissionService { }; } + const mappedMissionIds = missionIds.map( + (m: { mission_uuid: string }) => m.mission_uuid, + ); + + if (query.minimal) { + // Minimal projection logic (does not fetch tags, metadata, file stats) + const dataQuery = this.missionRepository + .createQueryBuilder('mission') + .leftJoinAndSelect('mission.project', 'project') + .leftJoinAndSelect('mission.creator', 'creator') + .where('mission.uuid IN (:...mappedMissionIds)', { + mappedMissionIds, + }); + + const sortedQuery = addSort( + dataQuery, + FIND_MANY_SORT_KEYS, + sortField, + order, + ); + const missions = await sortedQuery.getMany(); + + return { + data: missions.map((element) => + missionEntityToMinimumDto(element), + ), + count, + skip, + take, + }; + } + + // Full projection logic (similar to standard findMany/findMissionByProject) let dataQuery = this.missionRepository .createQueryBuilder('mission') .leftJoinAndSelect('mission.project', 'project') .leftJoinAndSelect('mission.creator', 'creator') .leftJoinAndSelect('mission.tags', 'tag') .leftJoinAndSelect('tag.tagType', 'tagType') - .where('mission.uuid IN (:...missionIds)', { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access - missionIds: missionIds.map((m) => m.mission_uuid), + .where('mission.uuid IN (:...mappedMissionIds)', { + mappedMissionIds, }); - if (sortBy !== undefined) { - dataQuery = addSort( - dataQuery, - FIND_MANY_SORT_KEYS, - sortBy, - sortOrder, - ); - } + dataQuery = addSort(dataQuery, FIND_MANY_SORT_KEYS, sortField, order); dataQuery = addFileStats(dataQuery); @@ -248,14 +293,13 @@ export class MissionService { >(); for (const raw of rawResults) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const missionUuid = raw.mission_uuid; + const mUuid = raw.mission_uuid; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - if (!statsMap.has(missionUuid)) { + if (!statsMap.has(mUuid)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - statsMap.set(missionUuid, { + statsMap.set(mUuid, { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access fileCount: Number.parseInt(raw.fileCount) || 0, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access fileSize: Number.parseInt(raw.fileSize) || 0, }); @@ -275,135 +319,11 @@ export class MissionService { } return { - data: missions.map((element) => missionEntityToFlatDto(element)), - count, - skip, - take, - }; - } - - async findMissionByProjectMinimal( - userUUID: string, - projectUUID: string, - skip: number, - take: number, - search?: string, - sortDirection?: 'ASC' | 'DESC', - sortBy?: string, - ): Promise { - const user = await this.userRepository.findOneOrFail({ - where: { uuid: userUUID }, - }); - - const query = this.missionRepository - .createQueryBuilder('mission') - .leftJoinAndSelect('mission.project', 'project') - .leftJoinAndSelect('mission.creator', 'creator') - .where('project.uuid = :projectUUID', { projectUUID }) - .take(take) - .skip(skip); - - if (search) { - const tokens = search.trim().split(/\s+/); - for (const [index, token] of tokens.entries()) { - query.andWhere(`mission.name ILIKE :search_${String(index)}`, { - [`search_${String(index)}`]: `%${token}%`, - }); - } - } - if (sortBy) { - query.orderBy(`mission.${sortBy}`, sortDirection); - } - if (user.role !== UserRole.ADMIN) { - addAccessConstraints(query, userUUID); - } - const [missions, count] = await query.getManyAndCount(); - - return { - data: missions.map((element) => missionEntityToMinimumDto(element)), - count, - skip, - take, - }; - } - - async findMissionByProject( - user: UserEntity, - projectUuid: string, - skip: number, - take: number, - search?: string, - sortDirection?: 'ASC' | 'DESC', - sortBy?: string, - ): Promise { - const query = this.missionRepository - .createQueryBuilder('mission') - .addSelect('COUNT(files.uuid)::int', 'fileCount') - .addSelect('COALESCE(SUM(files.size), 0)::bigint', 'totalSize') - .leftJoinAndSelect('mission.project', 'project') - .leftJoinAndSelect('mission.creator', 'creator') - .leftJoin('mission.files', 'files') - .leftJoinAndSelect('mission.tags', 'tags') - .leftJoinAndSelect('tags.tagType', 'tagType') - .where('project.uuid = :projectUuid', { projectUuid }) - .take(take) - .skip(skip); - - query - .addGroupBy('mission.uuid') - .addGroupBy('project.uuid') - .addGroupBy('project.name') - .addGroupBy('creator.uuid') - .addGroupBy('tags.uuid') - .addGroupBy('tagType.uuid'); - - if (search) { - const tokens = search.trim().split(/\s+/); - for (const [index, token] of tokens.entries()) { - query.andWhere(`mission.name ILIKE :search_${String(index)}`, { - [`search_${String(index)}`]: `%${token}%`, - }); - } - } - if (sortBy) { - query.orderBy(`mission.${sortBy}`, sortDirection); - } - if (user.role !== UserRole.ADMIN) { - addAccessConstraints(query, user.uuid); - } - - const count = await query.getCount(); - const { raw, entities } = await query.getRawAndEntities(); - - // this is necessary as raw and entities at not of the same length / order - // eslint-disable-next-line unicorn/no-array-reduce, @typescript-eslint/no-unsafe-assignment - const rawLookup = raw.reduce( - ( - lookup: Record, - - // eslint-disable-next-line @typescript-eslint/naming-convention - rawEntry: { mission_uuid: string }, - ) => { - lookup[rawEntry.mission_uuid] = rawEntry; - return lookup; - }, - {}, - ); - - return { - data: entities.map((m) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const rawEntry = rawLookup[m.uuid]; - return { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...missionEntityToDtoWithCreator(m), - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - filesCount: rawEntry?.fileCount, - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - size: Number.parseInt(rawEntry?.totalSize), - }; + data: missions.map((element) => { + const dto = missionEntityToFlatDto(element); + dto.filesCount = element.fileCount ?? 0; + dto.size = element.size ?? 0; + return dto; }), count, skip, @@ -486,13 +406,13 @@ export class MissionService { (_tag) => _tag.tagType?.uuid === tagTypeUUID, ); if (tag) { - return this.tagService.updateTagType( + return this.metadataService.updateTagType( missionUUID, tagTypeUUID, value, ); } - return this.tagService.addTagType( + return this.metadataService.addTagType( missionUUID, tagTypeUUID, value, @@ -527,10 +447,7 @@ export class MissionService { ); } - async updateName( - uuid: string, - name: string, - ): Promise { + async updateName(uuid: string, name: string): Promise { const exists = await this.missionRepository.exists({ where: { name: ILike(name), uuid: Not(uuid) }, }); @@ -542,6 +459,9 @@ export class MissionService { await this.missionRepository.update(uuid, { name: name, }); - return this.missionRepository.findOne({ where: { uuid } }); + return this.missionRepository.findOneOrFail({ + where: { uuid }, + relations: ['project', 'creator'], + }); } } diff --git a/backend/tests/actions/action-file-events.test.ts b/backend/tests/actions/action-file-events.test.ts index a1c847d87..08153af80 100644 --- a/backend/tests/actions/action-file-events.test.ts +++ b/backend/tests/actions/action-file-events.test.ts @@ -177,7 +177,7 @@ describe('Action File Events', () => { // 5. Download File using Action API Key const downloadResponse = await fetch( - `${DEFAULT_URL}/files/download?uuid=${file.uuid}&expires=false&preview_only=false`, + `${DEFAULT_URL}/files/${file.uuid}/download?expires=false&preview_only=false`, { method: 'GET', headers: { diff --git a/backend/tests/actions/api-key-rights.test.ts b/backend/tests/actions/api-key-rights.test.ts index 5e36cdfda..286a613b7 100644 --- a/backend/tests/actions/api-key-rights.test.ts +++ b/backend/tests/actions/api-key-rights.test.ts @@ -184,7 +184,7 @@ describe('Verify Action Access Rights', () => { // 2. Try to DELETE the mission using the Action API Key const deleteResponse = await fetch( - `${DEFAULT_URL}/mission/${globalThis.missionUuid}`, + `${DEFAULT_URL}/missions/${globalThis.missionUuid}`, { method: 'DELETE', @@ -238,7 +238,7 @@ describe('Verify Action Access Rights', () => { // 2. Try to GET the mission using the Action API Key const getResponse = await fetch( - `${DEFAULT_URL}/mission/one?uuid=${globalThis.missionUuid}`, + `${DEFAULT_URL}/missions/${globalThis.missionUuid}`, { method: 'GET', diff --git a/backend/tests/actions/test-actions.test.ts b/backend/tests/actions/test-actions.test.ts index d82c4f74f..f19a827ee 100644 --- a/backend/tests/actions/test-actions.test.ts +++ b/backend/tests/actions/test-actions.test.ts @@ -251,15 +251,15 @@ describe('Verify Action (Templates & Runs)', () => { }); test('if a user can view details of a submitted action', async () => { - // Debug: Check /user/me - const meResponse = await fetch(`${DEFAULT_URL}/user/me`, { + // Debug: Check /users/me + const meResponse = await fetch(`${DEFAULT_URL}/users/me`, { method: 'GET', // eslint-disable-next-line @typescript-eslint/no-unsafe-argument headers: new HeaderCreator(globalThis.creator).getHeaders(), }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const me = await meResponse.json(); - console.log('[DEBUG] /user/me:', me); + console.log('[DEBUG] /users/me:', me); // 1. Submit Action first // eslint-disable-next-line @typescript-eslint/no-unsafe-argument diff --git a/backend/tests/auth/access-groups/access-groups.test.ts b/backend/tests/auth/access-groups/access-groups.test.ts index 34d9f7cb1..1052edc32 100644 --- a/backend/tests/auth/access-groups/access-groups.test.ts +++ b/backend/tests/auth/access-groups/access-groups.test.ts @@ -58,7 +58,7 @@ describe('Verify Access Groups External', () => { // External user tries to view access groups const headers = new HeaderCreator(externalUser); const response = await fetch( - `${DEFAULT_URL}/access?search=&skip=0&take=20`, + `${DEFAULT_URL}/access-groups?search=&skip=0&take=20`, { method: 'GET', headers: headers.getHeaders() }, ); @@ -119,7 +119,7 @@ describe('Verify Access Groups Internal', () => { // Try to delete the primary group const headers = new HeaderCreator(user); const response = await fetch( - `${DEFAULT_URL}/access/${primaryGroupUuid}`, + `${DEFAULT_URL}/access-groups/${primaryGroupUuid}`, { method: 'DELETE', headers: headers.getHeaders() }, ); @@ -154,7 +154,7 @@ describe('Verify Access Groups Internal', () => { // Admin tries to delete other user's primary group const headers = new HeaderCreator(admin); const response = await fetch( - `${DEFAULT_URL}/access/${primaryGroupUuid}`, + `${DEFAULT_URL}/access-groups/${primaryGroupUuid}`, { method: 'DELETE', headers: headers.getHeaders() }, ); @@ -186,7 +186,7 @@ describe('Verify Access Groups Internal', () => { const headers = new HeaderCreator(user); const response = await fetch( - `${DEFAULT_URL}/access/${primaryGroupUuid}`, + `${DEFAULT_URL}/access-groups/${primaryGroupUuid}`, { method: 'DELETE', headers: headers.getHeaders() }, ); @@ -199,7 +199,7 @@ describe('Verify Access Groups Internal', () => { // Internal user should be able to search/filter access groups const headers = new HeaderCreator(user); const response = await fetch( - `${DEFAULT_URL}/access?search=&skip=0&take=20`, + `${DEFAULT_URL}/access-groups?search=&skip=0&take=20`, { method: 'GET', headers: headers.getHeaders() }, ); @@ -312,7 +312,7 @@ describe('Verify Access Groups Internal User Access', () => { // Internal user can search for groups const headers = new HeaderCreator(user); const response = await fetch( - `${DEFAULT_URL}/access?search=&skip=0&take=20`, + `${DEFAULT_URL}/access-groups?search=&skip=0&take=20`, { method: 'GET', headers: headers.getHeaders() }, ); expect(response.status).toBeLessThan(300); @@ -323,7 +323,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(user); const response = await fetch( - `${DEFAULT_URL}/access?search=&skip=0&take=20`, + `${DEFAULT_URL}/access-groups?search=&skip=0&take=20`, { method: 'GET', headers: headers.getHeaders() }, ); // External users lack CanCreate, so they get 403 @@ -335,7 +335,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(user); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/access`, { + const response = await fetch(`${DEFAULT_URL}/access-groups`, { method: 'POST', headers: headers.getHeaders(), body: JSON.stringify({ name: 'unauthorized_group' }), @@ -368,7 +368,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(externalUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'POST', headers: headers.getHeaders(), @@ -405,7 +405,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(externalUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users/${member.uuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users/${member.uuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -443,7 +443,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(externalUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'POST', headers: headers.getHeaders(), @@ -488,7 +488,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(externalUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -514,10 +514,13 @@ describe('Verify Access Groups Internal User Access', () => { ); const headers = new HeaderCreator(externalUser); - const response = await fetch(`${DEFAULT_URL}/access/${groupUuid}`, { - method: 'DELETE', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'DELETE', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBe(403); }); @@ -529,7 +532,7 @@ describe('Verify Access Groups Internal User Access', () => { // Internal user with CanCreate should be able to create groups const headers = new HeaderCreator(user); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/access`, { + const response = await fetch(`${DEFAULT_URL}/access-groups`, { method: 'POST', headers: headers.getHeaders(), body: JSON.stringify({ name: 'new_internal_group' }), @@ -557,7 +560,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(creator); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'POST', headers: headers.getHeaders(), @@ -589,7 +592,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(creator); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users/${member.uuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users/${member.uuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -622,7 +625,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(creator); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'DELETE', headers: headers.getHeaders(), @@ -656,7 +659,7 @@ describe('Verify Access Groups Internal User Access', () => { // Attempt to bulk remove the creator (last editor) and another member const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'DELETE', headers: headers.getHeaders(), @@ -703,7 +706,7 @@ describe('Verify Access Groups Internal User Access', () => { headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'DELETE', headers: headers.getHeaders(), @@ -764,7 +767,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(readUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'POST', headers: headers.getHeaders(), @@ -810,7 +813,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(writeUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'POST', headers: headers.getHeaders(), @@ -864,7 +867,7 @@ describe('Verify Access Groups Internal User Access', () => { const headers = new HeaderCreator(editUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -892,10 +895,13 @@ describe('Verify Access Groups Internal User Access', () => { // otherUser (not the creator/editor) tries to delete it const headers = new HeaderCreator(otherUser); - const response = await fetch(`${DEFAULT_URL}/access/${groupUuid}`, { - method: 'DELETE', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'DELETE', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBe(403); }); }); @@ -949,10 +955,13 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { // viewer should be able to view the group details const headers = new HeaderCreator(viewer); - const response = await fetch(`${DEFAULT_URL}/access/${groupUuid}`, { - method: 'GET', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'GET', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBeLessThan(300); const data = (await response.json()) as { name: string }; expect(data.name).toBe('viewable_group'); @@ -983,7 +992,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(readUser); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'POST', headers: headers.getHeaders(), @@ -1015,7 +1024,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(creator); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'POST', headers: headers.getHeaders(), @@ -1056,7 +1065,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(creator); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users/${removable.uuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users/${removable.uuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -1083,10 +1092,13 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { ); const headers = new HeaderCreator(admin); - const response = await fetch(`${DEFAULT_URL}/access/${groupUuid}`, { - method: 'GET', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'GET', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBeLessThan(300); }); @@ -1098,7 +1110,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/access`, { + const response = await fetch(`${DEFAULT_URL}/access-groups`, { method: 'POST', headers: headers.getHeaders(), body: JSON.stringify({ name: 'admin_created_group' }), @@ -1125,7 +1137,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users`, { method: 'POST', headers: headers.getHeaders(), @@ -1156,7 +1168,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/users/${member.uuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/users/${member.uuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -1189,7 +1201,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'POST', headers: headers.getHeaders(), @@ -1231,7 +1243,7 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/access/${groupUuid}/projects/${projectUuid}`, + `${DEFAULT_URL}/access-groups/${groupUuid}/projects/${projectUuid}`, { method: 'DELETE', headers: headers.getHeaders(), @@ -1253,10 +1265,13 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { ); const headers = new HeaderCreator(admin); - const response = await fetch(`${DEFAULT_URL}/access/${groupUuid}`, { - method: 'DELETE', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'DELETE', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBeLessThan(300); // Verify group is deleted @@ -1266,4 +1281,31 @@ describe('Verify Access Groups Internal User Access - CRUD and Admin', () => { }); expect(deleted).toBeNull(); }); + + test('if an unrelated user cannot view access group details', async () => { + const { user: creator } = await generateAndFetchDatabaseUser( + 'internal', + 'user', + ); + const { user: unrelated } = await generateAndFetchDatabaseUser( + 'internal', + 'user', + ); + + const groupUuid = await createAccessGroupUsingPost( + { name: 'private_group' }, + creator, + [creator], + ); + + const headers = new HeaderCreator(unrelated); + const response = await fetch( + `${DEFAULT_URL}/access-groups/${groupUuid}`, + { + method: 'GET', + headers: headers.getHeaders(), + }, + ); + expect(response.status).toBe(403); + }); }); diff --git a/backend/tests/auth/missions/mission-access.test.ts b/backend/tests/auth/missions/mission-access.test.ts index ff23f6013..1e6bcedf9 100644 --- a/backend/tests/auth/missions/mission-access.test.ts +++ b/backend/tests/auth/missions/mission-access.test.ts @@ -123,10 +123,10 @@ describe('Verify Mission Level Admin Access', () => { // Admin queries the mission const headers = new HeaderCreator(admin); - const response = await fetch( - `${DEFAULT_URL}/mission/one?uuid=${missionUuid}`, - { method: 'GET', headers: headers.getHeaders() }, - ); + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { + method: 'GET', + headers: headers.getHeaders(), + }); expect(response.status).toBeLessThan(300); }); @@ -149,14 +149,16 @@ describe('Verify Mission Level Admin Access', () => { // Admin updates mission name const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/updateName`, { - method: 'POST', - headers: headers.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - name: 'admin-renamed-mission', - }), - }); + const response = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/name`, + { + method: 'PATCH', + headers: headers.getHeaders(), + body: JSON.stringify({ + name: 'admin-renamed-mission', + }), + }, + ); expect(response.status).toBeLessThan(300); }); @@ -197,7 +199,7 @@ describe('Verify Mission Level Admin Access', () => { // Admin moves mission to another project const headers = new HeaderCreator(admin); const response = await fetch( - `${DEFAULT_URL}/mission/move?missionUUID=${missionUuid}&projectUUID=${projectUuid2}`, + `${DEFAULT_URL}/missions/${missionUuid}/move?projectUUID=${projectUuid2}`, { method: 'POST', headers: headers.getHeaders() }, ); expect(response.status).toBeLessThan(300); @@ -220,7 +222,7 @@ describe('Verify Mission Level Admin Access', () => { ); const headers = new HeaderCreator(admin); - const response = await fetch(`${DEFAULT_URL}/mission/${missionUuid}`, { + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: headers.getHeaders(), }); @@ -246,9 +248,15 @@ describe('Verify Mission Level Admin Access', () => { // Admin lists files in mission const headers = new HeaderCreator(admin); const response = await fetch( - `${DEFAULT_URL}/files/filtered?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, + `${DEFAULT_URL}/files?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, { method: 'GET', headers: headers.getHeaders() }, ); + if (response.status >= 300) { + console.log( + 'DEBUG: GET /files response error:', + await response.text(), + ); + } expect(response.status).toBeLessThan(300); }); @@ -273,7 +281,7 @@ describe('Verify Mission Level Admin Access', () => { const headers = new HeaderCreator(admin); const response = await fetch( - `${DEFAULT_URL}/files/filtered?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, + `${DEFAULT_URL}/files?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, { method: 'GET', headers: headers.getHeaders() }, ); expect(response.status).toBeLessThan(300); @@ -342,7 +350,7 @@ describe('Verify Mission Level Admin Access', () => { // Admin requests download link const headers = new HeaderCreator(admin); const response = await fetch( - `${DEFAULT_URL}/files/download?uuid=${file.uuid}&expires=true&preview_only=false`, + `${DEFAULT_URL}/files/${file.uuid}/download?expires=true&preview_only=false`, { method: 'GET', headers: headers.getHeaders() }, ); // May return 200 or could fail if S3 is not available, but should NOT return 403 @@ -432,8 +440,8 @@ describe('Verify Mission Level Admin Access', () => { // Admin moves the file const headers = new HeaderCreator(admin); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/files/moveFiles`, { - method: 'POST', + const response = await fetch(`${DEFAULT_URL}/files`, { + method: 'PATCH', headers: headers.getHeaders(), body: JSON.stringify({ fileUUIDs: [file.uuid], @@ -543,10 +551,10 @@ describe('Verify Mission Level User Access', () => { ); const headers = new HeaderCreator(readUser); - const response = await fetch( - `${DEFAULT_URL}/mission/one?uuid=${missionUuid}`, - { method: 'GET', headers: headers.getHeaders() }, - ); + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { + method: 'GET', + headers: headers.getHeaders(), + }); expect(response.status).toBeLessThan(300); }); @@ -569,14 +577,16 @@ describe('Verify Mission Level User Access', () => { // readUser tries to update mission name const headers = new HeaderCreator(readUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/updateName`, { - method: 'POST', - headers: headers.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - name: 'unauthorized-rename', - }), - }); + const response = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/name`, + { + method: 'PATCH', + headers: headers.getHeaders(), + body: JSON.stringify({ + name: 'unauthorized-rename', + }), + }, + ); expect(response.status).toBe(403); }); @@ -598,14 +608,16 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(readUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/tags`, { - method: 'POST', - headers: headers.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - tags: { custom: 'value' }, - }), - }); + const response = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/metadata`, + { + method: 'POST', + headers: headers.getHeaders(), + body: JSON.stringify({ + metadata: { custom: 'value' }, + }), + }, + ); expect(response.status).toBe(403); }); @@ -636,7 +648,7 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(readUser); const response = await fetch( - `${DEFAULT_URL}/mission/move?missionUUID=${missionUuid}&projectUUID=${targetProject}`, + `${DEFAULT_URL}/missions/${missionUuid}/move?projectUUID=${targetProject}`, { method: 'POST', headers: headers.getHeaders() }, ); expect(response.status).toBe(403); @@ -659,7 +671,7 @@ describe('Verify Mission Level User Access', () => { ); const headers = new HeaderCreator(readUser); - const response = await fetch(`${DEFAULT_URL}/mission/${missionUuid}`, { + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: headers.getHeaders(), }); @@ -685,14 +697,16 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(writeUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/updateName`, { - method: 'POST', - headers: headers.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - name: 'write-renamed-mission', - }), - }); + const response = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/name`, + { + method: 'PATCH', + headers: headers.getHeaders(), + body: JSON.stringify({ + name: 'renamed-mission', + }), + }, + ); expect(response.status).toBeLessThan(300); }); @@ -714,19 +728,24 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(writeUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/tags`, { - method: 'POST', - headers: headers.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - tags: { - [await createMetadataUsingPost( - { type: DataType.STRING, name: 'edit_metadata_tag' }, - creator, - )]: 'test_value', - }, - }), - }); + const response = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/metadata`, + { + method: 'POST', + headers: headers.getHeaders(), + body: JSON.stringify({ + metadata: { + [await createMetadataUsingPost( + { + type: DataType.STRING, + name: 'edit_metadata_tag', + }, + creator, + )]: 'test_value', + }, + }), + }, + ); expect(response.status).toBeLessThan(300); }); @@ -757,7 +776,7 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(writeUser); const response = await fetch( - `${DEFAULT_URL}/mission/move?missionUUID=${missionUuid}&projectUUID=${targetProject}`, + `${DEFAULT_URL}/missions/${missionUuid}/move?projectUUID=${targetProject}`, { method: 'POST', headers: headers.getHeaders() }, ); expect(response.status).toBe(403); @@ -780,7 +799,7 @@ describe('Verify Mission Level User Access', () => { ); const headers = new HeaderCreator(writeUser); - const response = await fetch(`${DEFAULT_URL}/mission/${missionUuid}`, { + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: headers.getHeaders(), }); @@ -840,7 +859,7 @@ describe('Verify Mission Level User Access', () => { const headers = new HeaderCreator(deleteUser); const response = await fetch( - `${DEFAULT_URL}/mission/move?missionUUID=${missionUuid}&projectUUID=${targetProject}`, + `${DEFAULT_URL}/missions/${missionUuid}/move?projectUUID=${targetProject}`, { method: 'POST', headers: headers.getHeaders() }, ); expect(response.status).toBeLessThan(300); @@ -863,7 +882,7 @@ describe('Verify Mission Level User Access', () => { ); const headers = new HeaderCreator(deleteUser); - const response = await fetch(`${DEFAULT_URL}/mission/${missionUuid}`, { + const response = await fetch(`${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: headers.getHeaders(), }); @@ -894,7 +913,7 @@ describe('Verify Mission File Level User Access', () => { const headers = new HeaderCreator(readUser); const response = await fetch( - `${DEFAULT_URL}/files/filtered?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, + `${DEFAULT_URL}/files?missionUUID=${missionUuid}&skip=0&take=10&sort=name&sortDirection=ASC&matchAllTopics=false`, { method: 'GET', headers: headers.getHeaders() }, ); expect(response.status).toBeLessThan(300); @@ -962,7 +981,7 @@ describe('Verify Mission File Level User Access', () => { const headers = new HeaderCreator(readUser); const response = await fetch( - `${DEFAULT_URL}/files/download?uuid=${file.uuid}&expires=true&preview_only=false`, + `${DEFAULT_URL}/files/${file.uuid}/download?expires=true&preview_only=false`, { method: 'GET', headers: headers.getHeaders() }, ); // Should not get 403 (may fail on S3 connectivity but not on access) @@ -1090,8 +1109,8 @@ describe('Verify Mission File Level User Access', () => { const headers = new HeaderCreator(readUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/files/moveFiles`, { - method: 'POST', + const response = await fetch(`${DEFAULT_URL}/files`, { + method: 'PATCH', headers: headers.getHeaders(), body: JSON.stringify({ fileUUIDs: [file.uuid], @@ -1224,8 +1243,8 @@ describe('Verify Mission File Level User Access', () => { const headers = new HeaderCreator(editUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/files/moveFiles`, { - method: 'POST', + const response = await fetch(`${DEFAULT_URL}/files`, { + method: 'PATCH', headers: headers.getHeaders(), body: JSON.stringify({ fileUUIDs: [file.uuid], @@ -1319,8 +1338,8 @@ describe('Verify Mission File Level User Access', () => { const headers = new HeaderCreator(deleteUser); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/files/moveFiles`, { - method: 'POST', + const response = await fetch(`${DEFAULT_URL}/files`, { + method: 'PATCH', headers: headers.getHeaders(), body: JSON.stringify({ fileUUIDs: [file.uuid], diff --git a/backend/tests/auth/project/project-creation-endpoint.test.ts b/backend/tests/auth/project/project-creation-endpoint.test.ts index aa00608b0..3ac1c06fe 100644 --- a/backend/tests/auth/project/project-creation-endpoint.test.ts +++ b/backend/tests/auth/project/project-creation-endpoint.test.ts @@ -419,7 +419,7 @@ describe('Verification project endpoint', () => { // delete mission const deleteMissionResponse = await fetch( - `${DEFAULT_URL}/mission/${missionUuid}`, + `${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: creatorHeader.getHeaders(), @@ -538,4 +538,28 @@ describe('Verification project endpoint', () => { // delete mission to allow cleanup await missionRepository.remove(mission); }); + + test('if logged-in user can fetch default-rights', async () => { + const header = new HeaderCreator(creator); + const response = await fetch(`${DEFAULT_URL}/projects/default-rights`, { + method: 'GET', + headers: header.getHeaders(), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body).toHaveProperty('data'); + expect(body).toHaveProperty('count'); + }); + + test('if user can fetch recent projects', async () => { + const header = new HeaderCreator(creator); + const response = await fetch(`${DEFAULT_URL}/projects/recent?take=10`, { + method: 'GET', + headers: header.getHeaders(), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body).toHaveProperty('data'); + expect(body).toHaveProperty('count'); + }); }); diff --git a/backend/tests/auth/project/project-edit-endpoints.test.ts b/backend/tests/auth/project/project-edit-endpoints.test.ts index 2f4a54667..fbeb1fbca 100644 --- a/backend/tests/auth/project/project-edit-endpoints.test.ts +++ b/backend/tests/auth/project/project-edit-endpoints.test.ts @@ -206,9 +206,9 @@ describe('Verify project manipulation endpoints', () => { headersBuilder.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/projects/${globalThis.projectUuid}/updateTagTypes`, + `${DEFAULT_URL}/projects/${globalThis.projectUuid}/metadata-types`, { - method: 'POST', + method: 'PUT', headers: headersBuilder.getHeaders(), body: JSON.stringify({ tagTypeUUIDs: [metadataUuid, globalThis.metadataUuid], @@ -216,6 +216,11 @@ describe('Verify project manipulation endpoints', () => { }, ); + if (response.status >= 300) { + console.error('API Error Response:', await response.text()); + } + expect(response.status).toBeLessThan(300); + const TagTypeRepository = database.getRepository(TagTypeEntity); const tagType = await TagTypeRepository.findOneOrFail({ @@ -225,7 +230,6 @@ describe('Verify project manipulation endpoints', () => { expect(tagType.name).toBe(name); expect(tagType.uuid).toBe(metadataUuid); expect(tagType.project?.[0]?.uuid).toBe(globalThis.projectUuid); - expect(response.status).toBeLessThan(300); }); test('if access management of project can be edited by creator', async () => { diff --git a/backend/tests/auth/project/user-admin-access.test.ts b/backend/tests/auth/project/user-admin-access.test.ts index 9cbd69d54..b00dac41f 100644 --- a/backend/tests/auth/project/user-admin-access.test.ts +++ b/backend/tests/auth/project/user-admin-access.test.ts @@ -265,7 +265,7 @@ describe('Verify project user/admin access', () => { for (const [, uuid] of missions.entries()) { // Check delete access - const response = await fetch(`${DEFAULT_URL}/mission/${uuid}`, { + const response = await fetch(`${DEFAULT_URL}/missions/${uuid}`, { method: 'DELETE', headers: headerCreator.getHeaders(), }); diff --git a/backend/tests/auth/tags/tags-access.test.ts b/backend/tests/auth/tags/tags-access.test.ts index 4ad982459..7652d6925 100644 --- a/backend/tests/auth/tags/tags-access.test.ts +++ b/backend/tests/auth/tags/tags-access.test.ts @@ -55,14 +55,16 @@ async function setupMissionWithTagValue( // Add tag value to mission via the API const tagHeaders = new HeaderCreator(creator); tagHeaders.addHeader('Content-Type', 'application/json'); - const tagResponse = await fetch(`${DEFAULT_URL}/mission/tags`, { - method: 'POST', - headers: tagHeaders.getHeaders(), - body: JSON.stringify({ - missionUUID: missionUuid, - tags: { [tagTypeUuid]: 'test_value' }, - }), - }); + const tagResponse = await fetch( + `${DEFAULT_URL}/missions/${missionUuid}/metadata`, + { + method: 'POST', + headers: tagHeaders.getHeaders(), + body: JSON.stringify({ + metadata: { [tagTypeUuid]: 'test_value' }, + }), + }, + ); expect(tagResponse.status).toBeLessThan(300); // Query the MetadataEntity UUID from the DB @@ -124,9 +126,9 @@ describe('Verify Project Level Access', () => { const headers = new HeaderCreator(viewer); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/projects/${projectUuid}/updateTagTypes`, + `${DEFAULT_URL}/projects/${projectUuid}/metadata-types`, { - method: 'POST', + method: 'PUT', headers: headers.getHeaders(), body: JSON.stringify({ tagTypeUUIDs: [tagUuid] }), }, @@ -169,9 +171,9 @@ describe('Verify Project Level Access', () => { const headers = new HeaderCreator(viewer); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/projects/${projectUuid}/updateTagTypes`, + `${DEFAULT_URL}/projects/${projectUuid}/metadata-types`, { - method: 'POST', + method: 'PUT', headers: headers.getHeaders(), body: JSON.stringify({ tagTypeUUIDs: [] }), }, @@ -213,9 +215,9 @@ describe('Verify Project Level Access', () => { const headers = new HeaderCreator(editor); headers.addHeader('Content-Type', 'application/json'); const response = await fetch( - `${DEFAULT_URL}/projects/${projectUuid}/updateTagTypes`, + `${DEFAULT_URL}/projects/${projectUuid}/metadata-types`, { - method: 'POST', + method: 'PUT', headers: headers.getHeaders(), body: JSON.stringify({ tagTypeUUIDs: [tagUuid] }), }, @@ -243,10 +245,13 @@ describe('Verify Project Level Access', () => { // Viewer tries to delete a tag value (requires WRITE on the mission) const headers = new HeaderCreator(viewer); - const response = await fetch(`${DEFAULT_URL}/tag/${tagValueUuid}`, { - method: 'DELETE', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/metadata/${tagValueUuid}`, + { + method: 'DELETE', + headers: headers.getHeaders(), + }, + ); expect(response.status).toBe(403); }); @@ -269,10 +274,13 @@ describe('Verify Project Level Access', () => { // Editor deletes a tag value const headers = new HeaderCreator(editor); - const response = await fetch(`${DEFAULT_URL}/tag/${tagValueUuid}`, { - method: 'DELETE', - headers: headers.getHeaders(), - }); + const response = await fetch( + `${DEFAULT_URL}/metadata/${tagValueUuid}`, + { + method: 'DELETE', + headers: headers.getHeaders(), + }, + ); // Guard allows the request (not 403). The endpoint itself returns 500 due to // a pre-existing DeleteTagDto response serialization bug, but the tag IS deleted. expect(response.status).not.toBe(403); @@ -413,7 +421,7 @@ describe('Verify tags/metadata type generation', () => { // Try to create same tag again—should fail const headers = new HeaderCreator(user); headers.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/tag/create`, { + const response = await fetch(`${DEFAULT_URL}/metadata-types`, { method: 'POST', headers: headers.getHeaders(), body: JSON.stringify({ diff --git a/backend/tests/files/file-crud.test.ts b/backend/tests/files/file-crud.test.ts index a3ba0e848..81c878414 100644 --- a/backend/tests/files/file-crud.test.ts +++ b/backend/tests/files/file-crud.test.ts @@ -40,7 +40,7 @@ describe('File Management Tests', () => { // Download const downloadResponse = await fetch( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${DEFAULT_URL}/files/download?uuid=${file?.uuid}&expires=false&preview_only=false`, + `${DEFAULT_URL}/files/${file?.uuid}/download?expires=false&preview_only=false`, { method: 'GET', headers: getAuthHeaders(user), @@ -95,21 +95,18 @@ describe('File Management Tests', () => { where: { filename: 'file2.bag' }, }); - const deleteResponse = await fetch( - `${DEFAULT_URL}/files/deleteMultiple`, - { - method: 'POST', - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Content-Type': 'application/json', - ...getAuthHeaders(user), - }, - body: JSON.stringify({ - uuids: [file1.uuid, file2.uuid], - missionUUID: missionUuid, - }), + const deleteResponse = await fetch(`${DEFAULT_URL}/files`, { + method: 'DELETE', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + ...getAuthHeaders(user), }, - ); + body: JSON.stringify({ + uuids: [file1.uuid, file2.uuid], + missionUUID: missionUuid, + }), + }); expect(deleteResponse.status).toBeLessThan(300); const deletedFile1 = await fileRepo.findOne({ @@ -149,8 +146,8 @@ describe('File Management Tests', () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition expect(file?.mission?.uuid).toBe(mission1Uuid); - const moveResponse = await fetch(`${DEFAULT_URL}/files/moveFiles`, { - method: 'POST', + const moveResponse = await fetch(`${DEFAULT_URL}/files`, { + method: 'PATCH', headers: { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/backend/tests/files/topic-extraction.test.ts b/backend/tests/files/topic-extraction.test.ts index 928bce45a..485dea0df 100644 --- a/backend/tests/files/topic-extraction.test.ts +++ b/backend/tests/files/topic-extraction.test.ts @@ -44,13 +44,10 @@ describe('Topic Extraction Tests', () => { // Verify topics via API // GET /topic/all console.log('[DEBUG] Fetching topics via API'); - const response = await fetch( - `${DEFAULT_URL}/topic/all?skip=0&take=10`, - { - method: 'GET', - headers: getAuthHeaders(user), - }, - ); + const response = await fetch(`${DEFAULT_URL}/topics?skip=0&take=10`, { + method: 'GET', + headers: getAuthHeaders(user), + }); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions console.log(`[DEBUG] API response status: ${response.status}`); expect(response.status).toBe(200); diff --git a/backend/tests/soft-delete/soft-delete-behavior.test.ts b/backend/tests/soft-delete/soft-delete-behavior.test.ts index 5b9d4edc8..d2c25249e 100644 --- a/backend/tests/soft-delete/soft-delete-behavior.test.ts +++ b/backend/tests/soft-delete/soft-delete-behavior.test.ts @@ -172,7 +172,7 @@ describe('Comprehensive Soft Delete Behavior', () => { // 2. Soft-delete Mission via API const header = new HeaderCreator(creator); const deleteResponse = await fetch( - `${DEFAULT_URL}/mission/${missionUuid}`, + `${DEFAULT_URL}/missions/${missionUuid}`, { method: 'DELETE', headers: header.getHeaders(), diff --git a/backend/tests/utils/api-calls.ts b/backend/tests/utils/api-calls.ts index 5a80e0e41..610c503c1 100644 --- a/backend/tests/utils/api-calls.ts +++ b/backend/tests/utils/api-calls.ts @@ -105,7 +105,7 @@ export const createMissionUsingPost = async ( const headersBuilder = new HeaderCreator(user); headersBuilder.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/mission/create`, { + const response = await fetch(`${DEFAULT_URL}/missions`, { method: 'POST', headers: headersBuilder.getHeaders(), body: JSON.stringify({ @@ -246,7 +246,7 @@ export const createMetadataUsingPost = async ( const headersBuilder = new HeaderCreator(user); headersBuilder.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/tag/create`, { + const response = await fetch(`${DEFAULT_URL}/metadata-types`, { method: 'POST', headers: headersBuilder.getHeaders(), body: JSON.stringify({ @@ -272,7 +272,7 @@ export const createAccessGroupUsingPost = async ( const headersBuilder = new HeaderCreator(creator); headersBuilder.addHeader('Content-Type', 'application/json'); - const response = await fetch(`${DEFAULT_URL}/access`, { + const response = await fetch(`${DEFAULT_URL}/access-groups`, { method: 'POST', headers: headersBuilder.getHeaders(), body: JSON.stringify({ diff --git a/backend/tests/utils/database-utilities.ts b/backend/tests/utils/database-utilities.ts index e7cd5a76c..1e6c128e6 100644 --- a/backend/tests/utils/database-utilities.ts +++ b/backend/tests/utils/database-utilities.ts @@ -26,7 +26,7 @@ export const database = new DataSource({ username: process.env.DB_USER ?? '', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_DATABASE ?? '', - synchronize: false, + synchronize: true, entities: ALL_ENTITIES, }); diff --git a/backend/tests/utils/endpoints.ts b/backend/tests/utils/endpoints.ts index d6ed76066..768ec22d5 100644 --- a/backend/tests/utils/endpoints.ts +++ b/backend/tests/utils/endpoints.ts @@ -5,7 +5,10 @@ export const getEndpoints = (): { url: string; method: string; }[] => { - const filePath = '.endpoints/__generated__endpoints.json'; + let filePath = '.endpoints/__generated__endpoints.json'; + if (!fs.existsSync(filePath)) { + filePath = '__generated__endpoints.json'; + } assert.ok( fs.existsSync(filePath), diff --git a/cli/kleinkram/api/client.py b/cli/kleinkram/api/client.py index 7d826044d..7a3747207 100644 --- a/cli/kleinkram/api/client.py +++ b/cli/kleinkram/api/client.py @@ -167,3 +167,12 @@ def request( return response else: return response + + def delete( # type: ignore[override] + self, + url: str | httpx.URL, + *, + params: QueryParams | None = None, + **kwargs: Any, + ) -> httpx.Response: + return self.request("DELETE", url, params=params, **kwargs) diff --git a/cli/kleinkram/api/file_transfer.py b/cli/kleinkram/api/file_transfer.py index 55ea79224..17927e8b0 100644 --- a/cli/kleinkram/api/file_transfer.py +++ b/cli/kleinkram/api/file_transfer.py @@ -34,10 +34,10 @@ UPLOAD_CREDS = "/files/temporaryAccess" UPLOAD_CONFIRM = "/files/upload/confirm" -UPLOAD_CANCEL = "/files/cancelUpload" +UPLOAD_CANCEL = "/files/uploads" DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 16 -DOWNLOAD_URL = "/files/download" +DOWNLOAD_URL = "/files/{}/download" MAX_UPLOAD_RETRIES = 3 S3_MAX_RETRIES = 60 # same as frontend @@ -76,7 +76,7 @@ def _cancel_file_upload(client: AuthenticatedClient, file_id: UUID, mission_id: "uuids": [str(file_id)], "missionUuid": str(mission_id), } - resp = client.post(UPLOAD_CANCEL, json=data) + resp = client.delete(UPLOAD_CANCEL, json=data) resp.raise_for_status() return @@ -256,7 +256,7 @@ def _get_file_download(client: AuthenticatedClient, id: UUID) -> str: """\ get the download url for a file by file id """ - resp = client.get(DOWNLOAD_URL, params={"uuid": str(id), "expires": True, "preview_only": False}) + resp = client.get(DOWNLOAD_URL.format(id), params={"expires": True, "preview_only": False}) if 400 <= resp.status_code < 500: raise AccessDenied( diff --git a/cli/kleinkram/api/routes.py b/cli/kleinkram/api/routes.py index 381bfffe3..1a799b5f9 100644 --- a/cli/kleinkram/api/routes.py +++ b/cli/kleinkram/api/routes.py @@ -86,12 +86,11 @@ ] -CLAIM_ADMIN = "/user/claimAdmin" -GET_STATUS = "/user/me" +CLAIM_ADMIN = "/users/admin/claim" +GET_STATUS = "/users/me" UPDATE_PROJECT = "/projects" -UPDATE_MISSION = "/missions/tags" # TODO: just metadata for now -CREATE_MISSION = "/missions/create" +CREATE_MISSION = "/missions" CREATE_PROJECT = "/projects" @@ -99,7 +98,7 @@ MISSION_ENDPOINT = "/missions" PROJECT_ENDPOINT = "/projects" -TAG_TYPE_BY_NAME = "/tag/filtered" +TAG_TYPE_BY_NAME = "/metadata-types/filtered" ACTION_ENDPOINT = "/action" @@ -481,10 +480,9 @@ def _create_project(client: AuthenticatedClient, project_name: str, description: def _update_mission(client: AuthenticatedClient, mission_id: UUID, *, tags: Dict[UUID, str]) -> None: payload = { - "missionUUID": str(mission_id), - "tags": {str(k): v for k, v in tags.items()}, + "metadata": {str(k): v for k, v in tags.items()}, } - resp = client.post(UPDATE_MISSION, json=payload) + resp = client.post(f"/missions/{mission_id}/metadata", json=payload) if resp.status_code == 404: raise MissionNotFound(f"Mission not found: {mission_id}") @@ -576,7 +574,7 @@ def _claim_admin(client: AuthenticatedClient) -> None: return -FILE_DELETE_MANY = "/files/deleteMultiple" +FILE_DELETE_MANY = "/files" def _delete_files(client: AuthenticatedClient, file_ids: Sequence[UUID], mission_id: UUID) -> None: @@ -584,7 +582,7 @@ def _delete_files(client: AuthenticatedClient, file_ids: Sequence[UUID], mission "uuids": [str(file_id) for file_id in file_ids], "missionUUID": str(mission_id), } - resp = client.post(FILE_DELETE_MANY, json=payload) + resp = client.delete(FILE_DELETE_MANY, json=payload) resp.raise_for_status() diff --git a/cli/kleinkram/core.py b/cli/kleinkram/core.py index e752d6dd1..3da5a2bd5 100644 --- a/cli/kleinkram/core.py +++ b/cli/kleinkram/core.py @@ -960,7 +960,7 @@ def _validate_tag_value(tag_value, tag_datatype) -> None: def _get_metadata_type_id_by_name(client: AuthenticatedClient, tag_name: str) -> Tuple[Optional[UUID], str]: - resp = client.get("/tag/filtered", params={"name": tag_name, "take": 1}) + resp = client.get("/metadata-types/filtered", params={"name": tag_name, "take": 1}) if resp.status_code in (403, 404): return None, "" diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile index aac19f5a6..edea2ce5e 100644 --- a/docker/backend.Dockerfile +++ b/docker/backend.Dockerfile @@ -38,6 +38,7 @@ WORKDIR /app COPY --from=build /app/backend/dist/main.js ./backend/dist/main.js COPY --from=build /app/backend/package.json ./backend/package.json COPY --from=build /app/backend/assets/favicon.png ./backend/assets/favicon.png +COPY --from=build /prod/backend/node_modules ./backend/node_modules WORKDIR /app/backend diff --git a/docker/queue-consumer.Dockerfile b/docker/queue-consumer.Dockerfile index 48e140816..bce066efa 100644 --- a/docker/queue-consumer.Dockerfile +++ b/docker/queue-consumer.Dockerfile @@ -50,6 +50,7 @@ RUN apt-get update && apt-get install -y curl ca-certificates && \ WORKDIR /app COPY --from=build /app/queueConsumer/dist/main.js ./queueConsumer/dist/main.js +COPY --from=build /prod/queueConsumer/node_modules ./queueConsumer/node_modules WORKDIR /app/queueConsumer diff --git a/frontend/src/services/mutations/access.ts b/frontend/src/services/mutations/access.ts index f456a44f5..833c34b63 100644 --- a/frontend/src/services/mutations/access.ts +++ b/frontend/src/services/mutations/access.ts @@ -26,7 +26,7 @@ export const addUsersToProject = async ( }; export const createAccessGroup = async (name: string) => { - const { data } = await axios.post('/access', { + const { data } = await axios.post('/access-groups', { name, }); return data; @@ -39,7 +39,7 @@ export const addUserToAccessGroup = async ( expireDate?: Date | 'never', ) => { const { data } = await axios.post( - `/access/${accessGroupUUID}/users`, + `/access-groups/${accessGroupUUID}/users`, { userUuid, canEditGroup, @@ -55,7 +55,7 @@ export const addAccessGroupToProject = async ( rights: AccessGroupRights, ) => { const { data } = await axios.post( - `/access/${accessGroupUUID}/projects/${projectUUID}`, + `/access-groups/${accessGroupUUID}/projects/${projectUUID}`, { rights, }, @@ -80,7 +80,7 @@ export const removeAccessGroupFromProject = async ( ) => { const { data } = await axios.delete( - `/access/${accessGroupUUID}/projects/${projectUUID}`, + `/access-groups/${accessGroupUUID}/projects/${projectUUID}`, ); return data; }; @@ -90,7 +90,7 @@ export const removeUsersFromAccessGroup = async ( accessGroupUUID: string, ) => { const { data } = await axios.delete( - `/access/${accessGroupUUID}/users`, + `/access-groups/${accessGroupUUID}/users`, { data: { userUuids }, }, @@ -100,7 +100,7 @@ export const removeUsersFromAccessGroup = async ( export const deleteAccessGroup = async (accessGroupUUID: string) => { const { data } = await axios.delete( - `/access/${accessGroupUUID}`, + `/access-groups/${accessGroupUUID}`, ); return data; }; @@ -111,7 +111,7 @@ export const setAccessGroupExpiry = async ( expiryDate: Date | null, ) => { const { data } = await axios.put( - `/access/${uuid}/users/${userUuid}/expiration`, + `/access-groups/${uuid}/users/${userUuid}/expiration`, { expireDate: expiryDate ?? 'never', }, @@ -125,7 +125,7 @@ export const setAccessGroupUserPermissions = async ( canEditGroup: boolean, ) => { const { data } = await axios.put( - `/access/${uuid}/users/${userUuid}/permissions`, + `/access-groups/${uuid}/users/${userUuid}/permissions`, { canEditGroup }, ); return data; diff --git a/frontend/src/services/mutations/categories.ts b/frontend/src/services/mutations/categories.ts index 732b5118e..2929595d1 100644 --- a/frontend/src/services/mutations/categories.ts +++ b/frontend/src/services/mutations/categories.ts @@ -1,7 +1,7 @@ import axios from 'src/api/axios'; export const createCategory = async (name: string, projectUUID: string) => { - const response = await axios.post('/category/create', { + const response = await axios.post('/categories', { name, projectUUID, }); @@ -14,7 +14,7 @@ export const addManyCategories = async ( files: string[], categories: string[], ) => { - const response = await axios.post('/category/addMany', { + const response = await axios.post('/categories/add-many', { missionUUID, files, categories, diff --git a/frontend/src/services/mutations/file.ts b/frontend/src/services/mutations/file.ts index 5edbaeac1..1e13f0cc5 100644 --- a/frontend/src/services/mutations/file.ts +++ b/frontend/src/services/mutations/file.ts @@ -48,13 +48,10 @@ export const moveFiles = async ( fileUUIDs: string[], missionUUID: string, ): Promise => { - const response = await axios.post( - '/files/moveFiles', - { - fileUUIDs, - missionUUID, - }, - ); + const response = await axios.patch('/files', { + fileUUIDs, + missionUUID, + }); return response.data; }; @@ -78,11 +75,13 @@ export const cancelUploads = async ( fileUuids: string[], missionUuid: string, ): Promise => { - const response = await axios.post( - '/files/cancelUpload', + const response = await axios.delete( + '/files/uploads', { - uuids: fileUuids, - missionUuid: missionUuid, + data: { + uuids: fileUuids, + missionUuid: missionUuid, + }, }, ); return response.data; @@ -92,13 +91,12 @@ export const deleteFiles = async ( fileUUIDs: string[], missionUUID: string, ): Promise => { - const response = await axios.post( - '/files/deleteMultiple', - { + const response = await axios.delete('/files', { + data: { uuids: fileUUIDs, missionUUID, }, - ); + }); return response.data; }; diff --git a/frontend/src/services/mutations/mission.ts b/frontend/src/services/mutations/mission.ts index d4ad39d42..c3b81bbf4 100644 --- a/frontend/src/services/mutations/mission.ts +++ b/frontend/src/services/mutations/mission.ts @@ -6,7 +6,7 @@ export const createMission = async ( projectUUID: string, tags: Record, ) => { - const response = await axios.post('/mission/create', { + const response = await axios.post('/missions', { name, projectUUID, tags, @@ -17,16 +17,16 @@ export const createMission = async ( export const moveMission = async (missionUUID: string, projectUUID: string) => { const response = await axios.post( - '/mission/move', + `/missions/${missionUUID}/move`, {}, - { params: { missionUUID, projectUUID } }, + { params: { projectUUID } }, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; export const deleteMission = async (mission: MissionWithFilesDto) => { - const response = await axios.delete(`/mission/${mission.uuid}`); + const response = await axios.delete(`/missions/${mission.uuid}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; @@ -35,14 +35,15 @@ export const updateMissionTags = async ( missionUUID: string, tags: Record, ) => { - const response = await axios.post('/mission/tags', { missionUUID, tags }); + const response = await axios.post(`/missions/${missionUUID}/metadata`, { + metadata: tags, + }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; export const updateMissionName = async (missionUUID: string, name: string) => { - const response = await axios.post('/mission/updateName', { - missionUUID, + const response = await axios.patch(`/missions/${missionUUID}/name`, { name, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return diff --git a/frontend/src/services/mutations/tag.ts b/frontend/src/services/mutations/tag.ts index 02132984d..889708c16 100644 --- a/frontend/src/services/mutations/tag.ts +++ b/frontend/src/services/mutations/tag.ts @@ -2,7 +2,7 @@ import { DataType } from '@kleinkram/shared'; import axios from 'src/api/axios'; export const removeTag = async (tagUUID: string) => { - const response = await axios.delete(`/tag/${tagUUID}`); + const response = await axios.delete(`/metadata/${tagUUID}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; @@ -11,16 +11,15 @@ export const addTags = async ( missionUUID: string, tags: Record, ) => { - const response = await axios.post('/tag/addTags', { - mission: missionUUID, - tags, + const response = await axios.post(`/missions/${missionUUID}/metadata`, { + metadata: tags, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; export const createTagType = async (name: string, type: DataType) => { - const response = await axios.post('/tag/create', { name, type }); + const response = await axios.post('/metadata-types', { name, type }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; diff --git a/frontend/src/services/queries/access.ts b/frontend/src/services/queries/access.ts index 035cb6536..6131f62e9 100644 --- a/frontend/src/services/queries/access.ts +++ b/frontend/src/services/queries/access.ts @@ -25,7 +25,7 @@ export const searchAccessGroups = async ( }; const response: AxiosResponse = await axios.get( - '/access', + '/access-groups', { params: parameters, }, @@ -35,7 +35,7 @@ export const searchAccessGroups = async ( }; export const getAccessGroup = async (uuid: string): Promise => { - const response = await axios.get(`/access/${uuid}`); + const response = await axios.get(`/access-groups/${uuid}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; @@ -51,7 +51,7 @@ export const getProjectAccess = async ( export const getAccessGroupAuditLogs = async ( uuid: string, ): Promise => { - const response = await axios.get(`/access/${uuid}/audit-logs`); + const response = await axios.get(`/access-groups/${uuid}/audit-logs`); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.data; }; diff --git a/frontend/src/services/queries/categories.ts b/frontend/src/services/queries/categories.ts index ac594ea6d..a321a53cb 100644 --- a/frontend/src/services/queries/categories.ts +++ b/frontend/src/services/queries/categories.ts @@ -7,14 +7,14 @@ export const getCategories = async ( filter?: string, ): Promise => { const parameters: { - uuid: string; + projectUuid: string; filter?: string; - } = { uuid: projectUUID }; + } = { projectUuid: projectUUID }; if (filter) { parameters.filter = filter; } const response: AxiosResponse = await axios.get( - '/category/all', + '/categories', { params: parameters, }, diff --git a/frontend/src/services/queries/file.ts b/frontend/src/services/queries/file.ts index 73aa869e7..2a58726f5 100644 --- a/frontend/src/services/queries/file.ts +++ b/frontend/src/services/queries/file.ts @@ -77,7 +77,7 @@ export const fetchFilteredFiles = async ( const queryParameters = new URLSearchParams(parameters).toString(); const response: AxiosResponse = await axios.get( - `/files/filtered?${queryParameters}`, + `/files?${queryParameters}`, ); return response.data; } catch (error) { @@ -89,9 +89,7 @@ export const fetchFilteredFiles = async ( export const fetchFile = async (uuid: string): Promise => { try { const response: AxiosResponse = - await axios.get('/files/one', { - params: { uuid }, - }); + await axios.get(`/files/${uuid}`); return response.data; } catch (error) { console.error('Error fetching file:', error); @@ -105,15 +103,16 @@ export const downloadFile = async ( // eslint-disable-next-line @typescript-eslint/naming-convention preview_only = false, ): Promise => { - const response = await axios.get('files/download', { - params: { - uuid, - - expires, - // eslint-disable-next-line @typescript-eslint/naming-convention - preview_only, + const response = await axios.get( + `/files/${uuid}/download`, + { + params: { + expires, + // eslint-disable-next-line @typescript-eslint/naming-convention + preview_only, + }, }, - }); + ); return response.data.url; }; diff --git a/frontend/src/services/queries/mission.ts b/frontend/src/services/queries/mission.ts index 619e5024c..4b3282804 100644 --- a/frontend/src/services/queries/mission.ts +++ b/frontend/src/services/queries/mission.ts @@ -5,12 +5,10 @@ import qs from 'qs'; import axios from 'src/api/axios'; export const getMission = async ( - uuid: string | undefined, + uuid: string, ): Promise => { const response: AxiosResponse = - await axios.get('/mission/one', { - params: { uuid }, - }); + await axios.get(`/missions/${uuid}`); return response.data; }; @@ -39,12 +37,13 @@ export const missionsOfProjectMinimal = async ( skip, sortBy, sortDirection: descending ? 'DESC' : 'ASC', + minimal: true, }; if (searchParameters?.name) { parameters.search = searchParameters.name; } const response: AxiosResponse = await axios.get( - `/mission/filteredMinimal`, + `/missions`, { params: parameters, }, @@ -82,7 +81,7 @@ export const missionsOfProject = async ( parameters.search = searchParameters.name; } const response: AxiosResponse = await axios.get( - `/mission/filtered`, + `/missions`, { params: parameters, }, @@ -92,7 +91,7 @@ export const missionsOfProject = async ( export const getMissions = async (uuids: string[]): Promise => { const response: AxiosResponse = await axios.get( - '/mission', + '/missions', { params: { missionUuids: uuids }, paramsSerializer: (parameters) => { diff --git a/frontend/src/services/queries/project.ts b/frontend/src/services/queries/project.ts index 750afdefe..8924e3b5f 100644 --- a/frontend/src/services/queries/project.ts +++ b/frontend/src/services/queries/project.ts @@ -45,7 +45,7 @@ export const getProject = async ( export const getProjectDefaultAccess = async (): Promise => { const response: AxiosResponse = await axios.get( - '/oldProject/getDefaultRights', + '/projects/default-rights', ); return response.data; }; @@ -53,7 +53,7 @@ export const getProjectDefaultAccess = async (): Promise => { export const recentProjects = async ( take: number, ): Promise => { - const response = await axios.get('/oldProject/recent', { + const response = await axios.get('/projects/recent', { params: { take }, }); return response.data; diff --git a/frontend/src/services/queries/tag.ts b/frontend/src/services/queries/tag.ts index dd36d1b4c..03f6c4820 100644 --- a/frontend/src/services/queries/tag.ts +++ b/frontend/src/services/queries/tag.ts @@ -7,7 +7,7 @@ import { AxiosResponse } from 'axios'; import axios from 'src/api/axios'; export const getTagTypes = async (): Promise => { - const response: AxiosResponse = await axios.get('/tag/all'); + const response: AxiosResponse = await axios.get('/metadata-types'); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return response.data.data ?? []; }; @@ -19,7 +19,7 @@ export const getFilteredTagTypes = async ( let response: AxiosResponse; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!name && type === null) { - response = await axios.get('/tag/all'); + response = await axios.get('/metadata-types'); } else { const parameters: Record = {}; if (name) { @@ -29,7 +29,7 @@ export const getFilteredTagTypes = async ( if (type !== null) { parameters.type = type ?? ''; } - response = await axios.get(`/tag/filtered`, { + response = await axios.get(`/metadata-types/filtered`, { params: parameters, }); } diff --git a/frontend/src/services/queries/topic.ts b/frontend/src/services/queries/topic.ts index 131522d55..3fbf7d04d 100644 --- a/frontend/src/services/queries/topic.ts +++ b/frontend/src/services/queries/topic.ts @@ -2,11 +2,11 @@ import type { TopicNamesDto, TopicTypesDto } from '@kleinkram/api-dto'; import axios from 'src/api/axios'; export const allTopicsNames = async (): Promise => { - const response = await axios.get('/topic/names'); + const response = await axios.get('/topics/names'); return response.data.data; }; export const allTopicTypes = async (): Promise => { - const response = await axios.get('/topic/types'); + const response = await axios.get('/topics/types'); return response.data.data; }; diff --git a/frontend/src/services/queries/user.ts b/frontend/src/services/queries/user.ts index f013aa1af..b9b820cb3 100644 --- a/frontend/src/services/queries/user.ts +++ b/frontend/src/services/queries/user.ts @@ -13,14 +13,14 @@ export const searchUsers = async (search: string): Promise => { }; } const response: AxiosResponse = await axios.get( - '/user/search', + '/users/search', { params: { search } }, ); return response.data; }; export const getMe = async (): Promise => { - const response = await axios.get('/user/me'); + const response = await axios.get('/users/me'); const user = response.data; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!user) throw new Error('User not found'); @@ -28,8 +28,9 @@ export const getMe = async (): Promise => { }; export const getPermissions = async (): Promise => { - const response: AxiosResponse = - await axios.get('/user/permissions'); + const response: AxiosResponse = await axios.get( + '/users/me/permissions', + ); return response.data; }; @@ -46,7 +47,7 @@ export const getMyApiKeys = async ( sortOrder: descending ? 'DESC' : 'ASC', }; - const response = await axios.get('/user/api-keys', { + const response = await axios.get('/users/me/api-keys', { params: parameters, }); return response.data; @@ -56,8 +57,11 @@ export const resolveUsers = async ( uuids: string[], ): Promise> => { if (uuids.length === 0) return {}; - const response = await axios.post>('/user/resolve', { - uuids, - }); + const response = await axios.post>( + '/users/resolve', + { + uuids, + }, + ); return response.data; }; diff --git a/frontend/src/services/queries/worker.ts b/frontend/src/services/queries/worker.ts index 915e739e1..bbd7aa129 100644 --- a/frontend/src/services/queries/worker.ts +++ b/frontend/src/services/queries/worker.ts @@ -4,6 +4,6 @@ import axios from 'src/api/axios'; export async function allWorkers(): Promise { const response: AxiosResponse = - await axios.get('/worker/all'); + await axios.get('/workers'); return response.data; } diff --git a/packages/api-dto/src/types/access-control/access-group.dto.ts b/packages/api-dto/src/types/access-control/access-group.dto.ts index 6edb4cb70..140dfb0ed 100644 --- a/packages/api-dto/src/types/access-control/access-group.dto.ts +++ b/packages/api-dto/src/types/access-control/access-group.dto.ts @@ -1,10 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { GroupMembershipDto } from '@api-dto/access-control/group-membership.dto'; import { ProjectWithAccessRightsDto } from '@api-dto/project/project-access.dto'; import { UserDto } from '@api-dto/user/user.dto'; import { AccessGroupType } from '@kleinkram/shared'; import { IsNotUndefined } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsBoolean, IsDate, @@ -15,29 +16,36 @@ import { ValidateNested, } from 'class-validator'; +@Expose() export class AccessGroupDto { @ApiProperty() @IsUUID() + @Expose() uuid!: string; @ApiProperty() @IsString() + @Expose() name!: string; @ApiProperty() @IsDate() + @Expose() createdAt!: Date; @ApiProperty() @IsDate() + @Expose() updatedAt!: Date; @ApiProperty() @IsEnum(AccessGroupType) + @Expose() type!: AccessGroupType; @ApiProperty() @IsBoolean() + @Expose() hidden!: boolean; @ApiProperty() @@ -45,20 +53,27 @@ export class AccessGroupDto { @IsOptional() @ValidateNested() @Type(() => UserDto) + @Expose() + @Transform(({ obj }) => obj.creator ?? null) creator!: UserDto | null; @ApiProperty({ type: () => [GroupMembershipDto] }) @ValidateNested({ each: true }) @Type(() => GroupMembershipDto) + @Expose() + @Transform(({ obj }) => obj.memberships ?? []) memberships!: GroupMembershipDto[]; @ApiProperty() @ValidateNested({ each: true }) @Type(() => ProjectWithAccessRightsDto) + @Expose() + @Transform(({ obj }) => obj.projectAccesses ?? []) projectAccesses!: ProjectWithAccessRightsDto[]; @ApiProperty({ required: false }) @IsOptional() @IsString() + @Expose() emailPattern?: string; } diff --git a/packages/api-dto/src/types/access-control/group-membership.dto.ts b/packages/api-dto/src/types/access-control/group-membership.dto.ts index 200e2941d..7c91a6865 100644 --- a/packages/api-dto/src/types/access-control/group-membership.dto.ts +++ b/packages/api-dto/src/types/access-control/group-membership.dto.ts @@ -1,8 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unnecessary-condition */ import { AccessGroupDto } from '@api-dto/access-control/access-group.dto'; import { UserDto } from '@api-dto/user/user.dto'; import { IsNotUndefined } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsBoolean, IsDate, @@ -12,13 +13,16 @@ import { ValidateNested, } from 'class-validator'; +@Expose() export class GroupMembershipDto { @ApiProperty() @IsUUID() + @Expose() uuid!: string; @ApiProperty() @IsDate() + @Expose() createdAt!: Date; @ValidateIf((_, value) => { @@ -28,6 +32,7 @@ export class GroupMembershipDto { }) @ApiProperty() @IsDate() + @Expose() updatedAt!: Date; @ValidateIf((_, value) => { @@ -37,15 +42,19 @@ export class GroupMembershipDto { }) @ApiProperty() @IsDate() + @Expose() + @Transform(({ obj }) => obj.expirationDate ?? null) expirationDate!: Date | null; @ApiProperty({ type: () => UserDto }) @ValidateNested() @Type(() => UserDto) + @Expose() user!: UserDto; @ApiProperty() @IsBoolean() + @Expose() canEditGroup!: boolean; @ApiProperty({ @@ -57,5 +66,16 @@ export class GroupMembershipDto { @IsOptional() @ValidateNested() @Type(() => AccessGroupDto) + @Expose() + @Transform(({ obj, options }) => { + // Check if accessGroup should be included + if ( + options?.groups?.includes('includeAccessGroup') && + obj.accessGroup + ) { + return obj.accessGroup; + } + return null; + }) accessGroup!: AccessGroupDto | null; } diff --git a/packages/api-dto/src/types/access-control/project-access.dto.ts b/packages/api-dto/src/types/access-control/project-access.dto.ts index da2e5a912..9aedc93e8 100644 --- a/packages/api-dto/src/types/access-control/project-access.dto.ts +++ b/packages/api-dto/src/types/access-control/project-access.dto.ts @@ -1,17 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { Paginated } from '@api-dto/pagination'; import { AccessGroupRights, AccessGroupType } from '@kleinkram/shared'; import { IsSkip, IsTake } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsEnum, IsNumber, IsString, ValidateNested } from 'class-validator'; +@Expose() export class ProjectAccessDto { @ApiProperty() @IsString() + @Expose() + @Transform(({ value, obj }) => obj.accessGroup?.uuid ?? value) uuid!: string; @ApiProperty() @IsString() + @Expose() + @Transform(({ value, obj }) => obj.accessGroup?.name ?? value) name!: string; @ApiProperty({ @@ -20,10 +26,16 @@ export class ProjectAccessDto { enum: AccessGroupType, }) @IsEnum(AccessGroupType) + @Expose() + @Transform(({ value, obj }) => obj.accessGroup?.type ?? value) type!: AccessGroupType; @ApiProperty() @IsNumber() + @Expose() + @Transform( + ({ value, obj }) => obj.accessGroup?.memberships?.length ?? value, + ) memberCount!: number; @ApiProperty({ @@ -32,6 +44,7 @@ export class ProjectAccessDto { enum: AccessGroupRights, }) @IsEnum(AccessGroupRights) + @Expose() rights!: AccessGroupRights; } diff --git a/packages/api-dto/src/types/add-metadata-type.dto.ts b/packages/api-dto/src/types/add-metadata-type.dto.ts new file mode 100644 index 000000000..74c96dd78 --- /dev/null +++ b/packages/api-dto/src/types/add-metadata-type.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; +import { IsAtLeastOnePresent } from './tags/add-tags.dto'; + +@IsAtLeastOnePresent(['metadataTypeUUID', 'tagTypeUUID']) +export class AddMetadataTypeQueryDto { + @IsOptional() + @IsUUID('4') + @ApiProperty({ required: false }) + tagTypeUUID?: string; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ required: false }) + metadataTypeUUID?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class AddMetadataTypeDto {} diff --git a/packages/api-dto/src/types/add-tag-type.dto.ts b/packages/api-dto/src/types/add-tag-type.dto.ts deleted file mode 100644 index bdf8f264f..000000000 --- a/packages/api-dto/src/types/add-tag-type.dto.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class AddTagTypeDto {} diff --git a/packages/api-dto/src/types/create-mission.dto.ts b/packages/api-dto/src/types/create-mission.dto.ts index fb9162031..a668e26f6 100644 --- a/packages/api-dto/src/types/create-mission.dto.ts +++ b/packages/api-dto/src/types/create-mission.dto.ts @@ -24,3 +24,11 @@ export class CreateMission { @IsOptional() ignoreTags!: boolean; } + +export class UpdateMissionNameDto { + @IsString() + @IsNotEmpty() + @IsValidMissionName() + @IsNoValidUUID() + name!: string; +} diff --git a/packages/api-dto/src/types/file/file-query.dto.ts b/packages/api-dto/src/types/file/file-query.dto.ts index 6f787c043..b8879e3d5 100644 --- a/packages/api-dto/src/types/file/file-query.dto.ts +++ b/packages/api-dto/src/types/file/file-query.dto.ts @@ -1,9 +1,14 @@ import { MissionQueryDto } from '@api-dto/mission/mission-query.dto'; +import { HealthStatus } from '@kleinkram/shared'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, + IsBoolean, + IsDate, + IsEnum, + IsObject, IsOptional, IsString, IsUUID, @@ -54,4 +59,109 @@ export class FileQueryDto extends MissionQueryDto { @IsString({ each: true }) @ApiProperty({ required: false }) categoryPatterns?: string[]; + + // Filters consolidated from FilteredFilesQueryDto + @IsOptional() + @IsString() + @ApiProperty({ required: false, description: 'Filter for Filename' }) + fileName?: string; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ + required: false, + description: 'UUID of Project to filter by', + }) + projectUUID?: string; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ + required: false, + description: 'UUID of Mission to filter by', + }) + missionUUID?: string; + + @IsOptional() + @IsDate() + @Type(() => Date) + @ApiProperty({ + required: false, + description: 'Date specifying the start of the filtered time range', + }) + startDate?: Date; + + @IsOptional() + @IsDate() + @Type(() => Date) + @ApiProperty({ + required: false, + description: 'Date specifying the end of the filtered time range', + }) + endDate?: Date; + + @IsOptional() + @IsString() + @ApiProperty({ + required: false, + description: 'Name of Topics (coma separated)', + }) + topics?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + required: false, + description: 'Message datatypes to filter by (coma separated)', + }) + messageDatatypes?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + required: false, + description: 'File types to filter by (coma separated)', + }) + fileTypes?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + required: false, + description: 'Categories to filter by (coma separated)', + }) + categories?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiProperty({ + required: false, + description: + 'Returned File needs all specified topics (true) or any specified topics (false)', + }) + matchAllTopics = false; + + @IsOptional() + @IsObject() + @ApiProperty({ + required: false, + description: 'Dictionary Tagtype name to Tag value', + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tags?: Record; + + @IsOptional() + @IsEnum(HealthStatus) + @ApiProperty({ + required: false, + enum: HealthStatus, + description: 'File health', + }) + health?: HealthStatus; + + @IsOptional() + @IsString() + @ApiProperty({ required: false }) + sort?: string; } diff --git a/packages/api-dto/src/types/file/file.dto.ts b/packages/api-dto/src/types/file/file.dto.ts index 042d034fa..195f365c6 100644 --- a/packages/api-dto/src/types/file/file.dto.ts +++ b/packages/api-dto/src/types/file/file.dto.ts @@ -1,10 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ import { CategoryDto } from '@api-dto/category.dto'; import { MissionDto } from '@api-dto/mission/mission.dto'; import { TopicDto } from '@api-dto/topic.dto'; import { UserDto } from '@api-dto/user/user.dto'; import { FileState, FileType } from '@kleinkram/shared'; import { ApiProperty } from '@nestjs/swagger'; -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsBoolean, IsDate, @@ -63,6 +64,7 @@ export class FileDto { @ApiProperty() @IsNumber() @Expose() + @Transform(({ value, obj }) => obj.size ?? value ?? 0) size!: number; @ApiProperty({ @@ -96,15 +98,21 @@ export class FileDto { @IsString() @IsOptional() @Expose() + @Transform(({ value, obj }) => obj.hash ?? value ?? '') hash!: string | null; @ApiProperty() @IsString() @IsOptional() @Expose() + @Transform( + ({ value, obj }) => + obj.parent?.uuid ?? obj.derivedFiles?.[0]?.uuid ?? value, + ) relatedFileUuid?: string | undefined; } +@Expose() export class FileWithTopicDto extends FileDto { @ApiProperty({ description: 'List of topics', @@ -113,6 +121,21 @@ export class FileWithTopicDto extends FileDto { @ValidateNested() @Type(() => TopicDto) @Expose() + @Transform(({ obj }) => { + let topics = obj.topics ?? []; + if (topics.length === 0 && obj.derivedFiles?.length) { + const derivedWithTopics = obj.derivedFiles.find( + (f: any) => f.topics && f.topics.length > 0, + ); + if (derivedWithTopics) { + topics = derivedWithTopics.topics ?? []; + } + } + if (topics.length === 0 && obj.parent?.topics?.length) { + topics = obj.parent.topics; + } + return topics; + }) topics!: TopicDto[]; // additional properties only used in frontend diff --git a/packages/api-dto/src/types/file/temporary-access-request.dto.ts b/packages/api-dto/src/types/file/temporary-access-request.dto.ts index 7cf12577f..9cb396644 100644 --- a/packages/api-dto/src/types/file/temporary-access-request.dto.ts +++ b/packages/api-dto/src/types/file/temporary-access-request.dto.ts @@ -1,5 +1,5 @@ import { FileSource } from '@kleinkram/shared'; -import { IsNoValidUUID } from '@kleinkram/validation'; +import { IsNoValidUUID, IsValidFileName } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, @@ -13,6 +13,7 @@ export class TemporaryAccessRequestDto { @IsString({ each: true }) @IsNotEmpty({ each: true }) @IsNoValidUUID({ each: true }) + @IsValidFileName({ each: true }) @ApiProperty({ description: 'Filenames for which to generate temporary access', }) diff --git a/packages/api-dto/src/types/index.ts b/packages/api-dto/src/types/index.ts index b5e1be3a2..801d7500e 100644 --- a/packages/api-dto/src/types/index.ts +++ b/packages/api-dto/src/types/index.ts @@ -29,7 +29,7 @@ export * from '@api-dto/actions/update-action-trigger.dto'; export * from '@api-dto/actions/update-template.dto'; export * from '@api-dto/actions/webhook-trigger-response.dto'; export * from '@api-dto/add-access-group-project.dto'; -export * from '@api-dto/add-tag-type.dto'; +export * from '@api-dto/add-metadata-type.dto'; export * from '@api-dto/add-user-access-group.dto'; export * from '@api-dto/add-user-project.dto'; export * from '@api-dto/available-providers.dto'; @@ -61,6 +61,7 @@ export * from '@api-dto/file/recalculate-hashes-response.dto'; export * from '@api-dto/file/reextract-topics-response.dto'; export * from '@api-dto/file/temporary-access-request.dto'; export * from '@api-dto/is-date-or-never.constraint'; +export * from '@api-dto/mission/mission-download.dto'; export * from '@api-dto/mission/mission-query.dto'; export * from '@api-dto/mission/mission-with-files.dto'; export * from '@api-dto/mission/mission.dto'; @@ -92,8 +93,8 @@ export * from '@api-dto/tags/delete-tag.dto'; export * from '@api-dto/tags/tags.dto'; export * from '@api-dto/topic.dto'; export * from '@api-dto/update-file.dto'; +export * from '@api-dto/update-metadata-types.dto'; export * from '@api-dto/update-tag-type.dto'; -export * from '@api-dto/update-tag-types.dto'; export * from '@api-dto/upload.dto'; export * from '@api-dto/user/api-key-metadata.dto'; export * from '@api-dto/user/api-keys.dto'; @@ -101,4 +102,9 @@ export * from '@api-dto/user/current-api-user.dto'; export * from '@api-dto/user/resolve-users.dto'; export * from '@api-dto/user/user.dto'; +export * from '@api-dto/message.dto'; +export * from '@api-dto/success-response.dto'; export * from '@api-dto/user/users.dto'; + +export * from '@api-dto/metadata/filtered-metadata-types-query.dto'; +export * from '@api-dto/templates/action-templates-query.dto'; diff --git a/packages/api-dto/src/types/message.dto.ts b/packages/api-dto/src/types/message.dto.ts new file mode 100644 index 000000000..255069105 --- /dev/null +++ b/packages/api-dto/src/types/message.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class MessageDto { + @ApiProperty({ + description: 'A message describing the response status', + example: 'Token is valid', + type: String, + }) + @IsString() + message!: string; +} diff --git a/packages/api-dto/src/types/metadata/filtered-metadata-types-query.dto.ts b/packages/api-dto/src/types/metadata/filtered-metadata-types-query.dto.ts new file mode 100644 index 000000000..5de5372a8 --- /dev/null +++ b/packages/api-dto/src/types/metadata/filtered-metadata-types-query.dto.ts @@ -0,0 +1,35 @@ +import { DataType } from '@kleinkram/shared'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class FilteredMetadataTypesQueryDto { + @IsOptional() + @IsString() + @ApiProperty({ required: false, description: 'Filter by TagType name' }) + name?: string; + + @IsOptional() + @IsEnum(DataType) + @ApiProperty({ + required: false, + enum: DataType, + description: 'Filter by TagType datatype', + }) + type?: DataType; + + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + @ApiProperty({ required: false, default: 0 }) + skip = 0; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + @ApiProperty({ required: false, default: 100 }) + take = 100; +} diff --git a/packages/api-dto/src/types/mission/mission-download.dto.ts b/packages/api-dto/src/types/mission/mission-download.dto.ts new file mode 100644 index 000000000..6c018c33a --- /dev/null +++ b/packages/api-dto/src/types/mission/mission-download.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsUrl } from 'class-validator'; + +export class MissionDownloadEntryDto { + @ApiProperty({ + description: 'The name of the file', + }) + @IsString() + filename!: string; + + @ApiProperty({ + description: 'The download URL of the file', + }) + @IsUrl({ + // eslint-disable-next-line @typescript-eslint/naming-convention + require_tld: false, + }) + link!: string; +} diff --git a/packages/api-dto/src/types/mission/mission-query.dto.ts b/packages/api-dto/src/types/mission/mission-query.dto.ts index 18e06f362..97c0aaaff 100644 --- a/packages/api-dto/src/types/mission/mission-query.dto.ts +++ b/packages/api-dto/src/types/mission/mission-query.dto.ts @@ -1,10 +1,12 @@ import { ProjectQueryDto } from '@api-dto/project/project-query.dto'; import { IsRecordStringString } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, + IsBoolean, + IsEnum, IsNotEmptyObject, IsOptional, IsString, @@ -35,4 +37,31 @@ export class MissionQueryDto extends ProjectQueryDto { @IsRecordStringString() @ApiProperty({ required: false }) metadata?: Record; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiProperty({ + required: false, + description: 'Return minimal mission info', + }) + minimal?: boolean; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ required: false, description: 'Project UUID to filter by' }) + projectUuid?: string; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ + required: false, + description: 'Backwards-compatible Project UUID', + }) + uuid?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + @ApiProperty({ required: false, enum: ['ASC', 'DESC'] }) + sortDirection?: 'ASC' | 'DESC'; } diff --git a/packages/api-dto/src/types/mission/mission-with-files.dto.ts b/packages/api-dto/src/types/mission/mission-with-files.dto.ts index 64d40135b..73c560d10 100644 --- a/packages/api-dto/src/types/mission/mission-with-files.dto.ts +++ b/packages/api-dto/src/types/mission/mission-with-files.dto.ts @@ -1,9 +1,10 @@ import { FileDto } from '@api-dto/file/file.dto'; import { MissionWithCreatorDto } from '@api-dto/mission/mission.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; +@Expose() export class MissionWithFilesDto extends MissionWithCreatorDto { @ApiProperty({ type: () => FileDto, @@ -11,5 +12,6 @@ export class MissionWithFilesDto extends MissionWithCreatorDto { }) @ValidateNested() @Type(() => FileDto) + @Expose() files!: FileDto[]; } diff --git a/packages/api-dto/src/types/mission/mission.dto.ts b/packages/api-dto/src/types/mission/mission.dto.ts index 7100e7703..3410db664 100644 --- a/packages/api-dto/src/types/mission/mission.dto.ts +++ b/packages/api-dto/src/types/mission/mission.dto.ts @@ -1,10 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { Paginated } from '@api-dto/pagination'; import { ProjectDto } from '@api-dto/project/base-project.dto'; import { TagDto } from '@api-dto/tags/tags.dto'; import { UserDto } from '@api-dto/user/user.dto'; import { IsSkip, IsTake } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsDate, IsInt, @@ -70,13 +71,18 @@ export class MissionWithCreatorDto extends MissionDto { creator!: UserDto; } +@Expose() export class FlatMissionDto extends MissionWithCreatorDto { @ApiProperty() @IsNumber() + @Expose() + @Transform(({ value, obj }) => obj.fileCount ?? value ?? 0) filesCount!: number; @ApiProperty() @IsInt() + @Expose() + @Transform(({ value, obj }) => obj.size ?? value ?? 0) size!: number; } diff --git a/packages/api-dto/src/types/pagination.ts b/packages/api-dto/src/types/pagination.ts index fd7647269..1695881d9 100644 --- a/packages/api-dto/src/types/pagination.ts +++ b/packages/api-dto/src/types/pagination.ts @@ -45,8 +45,11 @@ export class SortablePaginatedQueryDto extends PaginatedQueryDto { sortBy?: string; // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return - @Transform(({ value }) => SortOrder[value.toUpperCase()]) + @Transform(({ value }) => + value && typeof value === 'string' + ? SortOrder[value.toUpperCase() as keyof typeof SortOrder] + : SortOrder.ASC, + ) @IsEnum(SortOrder) @ApiProperty({ required: false }) sortOrder: SortOrder = SortOrder.ASC; diff --git a/packages/api-dto/src/types/project/project-access.dto.ts b/packages/api-dto/src/types/project/project-access.dto.ts index ad55b50d4..59a55a723 100644 --- a/packages/api-dto/src/types/project/project-access.dto.ts +++ b/packages/api-dto/src/types/project/project-access.dto.ts @@ -1,9 +1,11 @@ import { ProjectDto } from '@api-dto/project/base-project.dto'; import { AccessGroupRights } from '@kleinkram/shared'; +import { Expose } from 'class-transformer'; import { IsEnum } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +@Expose() export class ProjectWithAccessRightsDto extends ProjectDto { @ApiProperty({ description: 'Access Group Rights', @@ -11,5 +13,6 @@ export class ProjectWithAccessRightsDto extends ProjectDto { enum: AccessGroupRights, }) @IsEnum(AccessGroupRights) + @Expose() rights!: AccessGroupRights; } diff --git a/packages/api-dto/src/types/project/project-with-creator.dto.ts b/packages/api-dto/src/types/project/project-with-creator.dto.ts index 8912d6efc..2c1852a20 100644 --- a/packages/api-dto/src/types/project/project-with-creator.dto.ts +++ b/packages/api-dto/src/types/project/project-with-creator.dto.ts @@ -1,12 +1,14 @@ import { ProjectDto } from '@api-dto/project/base-project.dto'; import { UserDto } from '@api-dto/user/user.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; +@Expose() export class ProjectWithCreator extends ProjectDto { @ApiProperty() @ValidateNested() @Type(() => UserDto) + @Expose() creator!: UserDto; } diff --git a/packages/api-dto/src/types/project/project-with-missions.dto.ts b/packages/api-dto/src/types/project/project-with-missions.dto.ts index 4f6cca169..1356b3648 100644 --- a/packages/api-dto/src/types/project/project-with-missions.dto.ts +++ b/packages/api-dto/src/types/project/project-with-missions.dto.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { FlatMissionDto } from '@api-dto/mission/mission.dto'; import { ProjectWithRequiredTagsDto } from '@api-dto/project/project-with-required-tags.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; +@Expose() export class ProjectWithMissionsDto extends ProjectWithRequiredTagsDto { @ApiProperty({ type: () => [FlatMissionDto], @@ -11,5 +13,7 @@ export class ProjectWithMissionsDto extends ProjectWithRequiredTagsDto { }) @ValidateNested() @Type(() => FlatMissionDto) + @Expose() + @Transform(({ obj }) => obj.missions ?? []) missions!: FlatMissionDto[]; } diff --git a/packages/api-dto/src/types/project/project-with-required-tags.dto.ts b/packages/api-dto/src/types/project/project-with-required-tags.dto.ts index 4ec028c1d..aa3303d66 100644 --- a/packages/api-dto/src/types/project/project-with-required-tags.dto.ts +++ b/packages/api-dto/src/types/project/project-with-required-tags.dto.ts @@ -1,20 +1,26 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { ProjectWithCreator } from '@api-dto/project/project-with-creator.dto'; import { TagTypeDto } from '@api-dto/tags/tags.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsNumber, ValidateNested } from 'class-validator'; +@Expose() export class ProjectWithRequiredTagsDto extends ProjectWithCreator { @ApiProperty({ description: 'Number of missions', }) @IsNumber() + @Expose() + @Transform(({ obj }) => obj.missionCount ?? 0) missionCount!: number; @ApiProperty({ description: 'Total size of files in byte', }) @IsNumber() + @Expose() + @Transform(({ obj }) => obj.size ?? 0) size!: number; @ApiProperty({ @@ -23,5 +29,7 @@ export class ProjectWithRequiredTagsDto extends ProjectWithCreator { }) @ValidateNested() @Type(() => TagTypeDto) + @Expose() + @Transform(({ obj }) => obj.requiredTags ?? []) requiredTags!: TagTypeDto[]; } diff --git a/packages/api-dto/src/types/success-response.dto.ts b/packages/api-dto/src/types/success-response.dto.ts new file mode 100644 index 000000000..179a067e7 --- /dev/null +++ b/packages/api-dto/src/types/success-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class SuccessResponseDto { + @ApiProperty({ + description: 'Indicates the operation was successful', + example: true, + type: Boolean, + }) + @IsBoolean() + success!: boolean; +} diff --git a/packages/api-dto/src/types/tags/add-tags.dto.ts b/packages/api-dto/src/types/tags/add-tags.dto.ts index 95bf7d757..1e84886b5 100644 --- a/packages/api-dto/src/types/tags/add-tags.dto.ts +++ b/packages/api-dto/src/types/tags/add-tags.dto.ts @@ -1,4 +1,79 @@ -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class AddTagsDto {} -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class AddTagDto {} +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsObject, + IsOptional, + registerDecorator, + ValidationArguments, +} from 'class-validator'; + +export function IsAtLeastOnePresent( + fields: string[], +): (target: new (...args: unknown[]) => unknown) => void { + return function (target: new (...args: unknown[]) => unknown) { + registerDecorator({ + name: 'isAtLeastOnePresent', + target: target, + propertyName: '', + constraints: [fields], + validator: { + validate(_value: unknown, args: ValidationArguments) { + const [relatedFields] = args.constraints as [string[]]; + const object = args.object as + | Record + | undefined; + return relatedFields.some( + (field) => + object?.[field] !== undefined && + object[field] !== null, + ); + }, + defaultMessage(args: ValidationArguments) { + const [relatedFields] = args.constraints as [string[]]; + return `At least one of the following fields must be present: ${relatedFields.join(', ')}`; + }, + }, + }); + }; +} + +@IsAtLeastOnePresent(['metadata', 'tags']) +export class AddTagsRequestDto { + @ApiProperty({ + description: 'Metadata key-value pairs', + required: false, + type: Object, + }) + @IsOptional() + @IsObject() + metadata?: Record; + + @ApiProperty({ + description: 'Tags key-value pairs', + required: false, + type: Object, + }) + @IsOptional() + @IsObject() + tags?: Record; +} + +export class AddTagsDto { + @ApiProperty({ + description: 'Indicates the operation was successful', + example: true, + type: Boolean, + }) + @IsBoolean() + success!: boolean; +} + +export class AddTagDto { + @ApiProperty({ + description: 'Indicates the operation was successful', + example: true, + type: Boolean, + }) + @IsBoolean() + success!: boolean; +} diff --git a/packages/api-dto/src/types/tags/tags.dto.ts b/packages/api-dto/src/types/tags/tags.dto.ts index b6145d0ca..b6e024fd9 100644 --- a/packages/api-dto/src/types/tags/tags.dto.ts +++ b/packages/api-dto/src/types/tags/tags.dto.ts @@ -1,8 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { Paginated } from '@api-dto/pagination'; import { DataType } from '@kleinkram/shared'; import { IsSkip, IsTake } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsDate, IsDefined, @@ -47,6 +48,7 @@ export class TagTypeDto { @ApiProperty() @IsString() @Expose() + @Transform(({ value, obj }) => obj.description ?? value ?? '') description?: string; } @@ -70,6 +72,7 @@ export class TagDto { @ApiProperty() @IsString() @Expose() + @Transform(({ value, obj }) => obj.tagType?.name ?? value) name!: string; @ApiProperty({ @@ -79,6 +82,7 @@ export class TagDto { }) @IsEnum(DataType) @Expose() + @Transform(({ value, obj }) => obj.tagType?.datatype ?? value) datatype!: DataType; @ApiProperty({ @@ -88,11 +92,22 @@ export class TagDto { @ValidateNested() @Type(() => TagTypeDto) @Expose() + @Transform(({ value, obj }) => obj.tagType ?? value) type!: TagTypeDto; @ApiProperty() @IsDefined() @Expose() + @Transform(({ value, obj }) => { + return ( + obj.value_string ?? + obj.value_number ?? + obj.value_boolean ?? + obj.value_date ?? + obj.value_location ?? + value + ); + }) value!: string | Date | number | boolean; get valueAsString(): string { diff --git a/packages/api-dto/src/types/templates/action-templates-query.dto.ts b/packages/api-dto/src/types/templates/action-templates-query.dto.ts new file mode 100644 index 000000000..b91dd91ba --- /dev/null +++ b/packages/api-dto/src/types/templates/action-templates-query.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class ActionTemplatesQueryDto { + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + @ApiProperty({ required: false, default: 0 }) + skip = 0; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + @ApiProperty({ required: false, default: 100 }) + take = 100; + + @IsOptional() + @IsString() + @ApiProperty({ required: false }) + search?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiProperty({ required: false, default: false }) + includeArchived?: boolean; +} diff --git a/packages/api-dto/src/types/topic.dto.ts b/packages/api-dto/src/types/topic.dto.ts index fa425e3e4..a15427be5 100644 --- a/packages/api-dto/src/types/topic.dto.ts +++ b/packages/api-dto/src/types/topic.dto.ts @@ -1,7 +1,8 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { Paginated } from '@api-dto/pagination'; import { IsSkip, IsTake } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { IsArray, IsNumber, IsString, ValidateNested } from 'class-validator'; @Expose() @@ -19,11 +20,13 @@ export class TopicDto { @ApiProperty() @IsNumber() @Expose() + @Transform(({ obj }) => obj.nrMessages ?? 0n) nrMessages?: bigint; @ApiProperty() @IsNumber() @Expose() + @Transform(({ obj }) => (Number.isNaN(obj.frequency) ? 0 : obj.frequency)) frequency!: number; } diff --git a/packages/api-dto/src/types/update-metadata-types.dto.ts b/packages/api-dto/src/types/update-metadata-types.dto.ts new file mode 100644 index 000000000..44c163f0d --- /dev/null +++ b/packages/api-dto/src/types/update-metadata-types.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator'; +import { IsAtLeastOnePresent } from './tags/add-tags.dto'; + +@IsAtLeastOnePresent(['metadataTypeUUIDs', 'tagTypeUUIDs']) +export class UpdateMetadataTypesBodyDto { + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + @ApiProperty({ required: false, type: [String] }) + tagTypeUUIDs?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + @ApiProperty({ required: false, type: [String] }) + metadataTypeUUIDs?: string[]; +} + +export class UpdateMetadataTypesDto { + @ApiProperty() + @IsBoolean() + success!: boolean; +} diff --git a/packages/api-dto/src/types/update-tag-types.dto.ts b/packages/api-dto/src/types/update-tag-types.dto.ts deleted file mode 100644 index e4620207e..000000000 --- a/packages/api-dto/src/types/update-tag-types.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean } from 'class-validator'; - -export class UpdateTagTypesDto { - @ApiProperty() - @IsBoolean() - success!: boolean; -} diff --git a/packages/api-dto/src/types/user/api-key-metadata.dto.ts b/packages/api-dto/src/types/user/api-key-metadata.dto.ts index 733252a34..bcfb34b22 100644 --- a/packages/api-dto/src/types/user/api-key-metadata.dto.ts +++ b/packages/api-dto/src/types/user/api-key-metadata.dto.ts @@ -1,5 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ import { AccessGroupRights, KeyTypes } from '@kleinkram/shared'; import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform } from 'class-transformer'; import { IsBoolean, IsDate, @@ -10,60 +12,83 @@ import { IsUUID, } from 'class-validator'; +@Expose() export class ApiKeyMetadataDto { @ApiProperty() @IsUUID() + @Expose() uuid!: string; @ApiProperty({ enum: KeyTypes }) @IsEnum(KeyTypes) + @Expose() + @Transform(({ value, obj }) => obj.key_type ?? value) keyType!: KeyTypes; @ApiProperty({ enum: AccessGroupRights }) @IsEnum(AccessGroupRights) + @Expose() rights!: AccessGroupRights; @ApiProperty({ description: 'Whether the key has been soft-deleted (expired)', }) @IsBoolean() + @Expose() + @Transform(({ value, obj }) => + obj.deletedAt === undefined ? value : !!obj.deletedAt, + ) expired!: boolean; @ApiProperty() @IsDate() + @Expose() createdAt!: Date; @ApiProperty() @IsDate() + @Expose() updatedAt!: Date; @ApiProperty({ required: false }) @IsOptional() @IsUUID() + @Expose() + @Transform(({ value, obj }) => obj.mission?.uuid ?? value) missionUuid?: string; @ApiProperty({ required: false }) @IsOptional() @IsString() + @Expose() + @Transform(({ value, obj }) => obj.mission?.name ?? value) missionName?: string; @ApiProperty({ required: false }) @IsOptional() @IsUUID() + @Expose() + @Transform(({ value, obj }) => obj.action?.uuid ?? value) actionUuid?: string; @ApiProperty({ required: false }) @IsOptional() @IsString() + @Expose() + @Transform(({ value, obj }) => obj.action?.template?.name ?? value) actionTemplateName?: string; @ApiProperty({ required: false }) @IsOptional() @IsInt() + @Expose() + @Transform(({ value, obj }) => obj.action?.template?.version ?? value) actionTemplateVersion?: number; @ApiProperty({ required: false }) @IsOptional() @IsUUID() + @Expose() + @Transform(({ value, obj }) => obj.mission?.project?.uuid ?? value) projectUuid?: string; } diff --git a/packages/api-dto/src/types/user/current-api-user.dto.ts b/packages/api-dto/src/types/user/current-api-user.dto.ts index 74601bcc3..d4856db50 100644 --- a/packages/api-dto/src/types/user/current-api-user.dto.ts +++ b/packages/api-dto/src/types/user/current-api-user.dto.ts @@ -2,9 +2,10 @@ import { GroupMembershipDto } from '@api-dto/access-control/group-membership.dto import { UserDto } from '@api-dto/user/user.dto'; import { UserRole } from '@kleinkram/shared'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { IsEnum, ValidateNested } from 'class-validator'; +@Expose() export class CurrentAPIUserDto extends UserDto { @ApiProperty({ type: () => [GroupMembershipDto], @@ -12,9 +13,11 @@ export class CurrentAPIUserDto extends UserDto { }) @ValidateNested({ each: true }) @Type(() => GroupMembershipDto) + @Expose() memberships!: GroupMembershipDto[]; @ApiProperty() @IsEnum(UserRole) + @Expose() role!: UserRole; } diff --git a/packages/api-dto/src/types/user/user.dto.ts b/packages/api-dto/src/types/user/user.dto.ts index c7a3f9004..d7194c096 100644 --- a/packages/api-dto/src/types/user/user.dto.ts +++ b/packages/api-dto/src/types/user/user.dto.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unnecessary-condition */ import { IsNotUndefined } from '@kleinkram/validation'; import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; +import { Expose, Transform } from 'class-transformer'; import { IsEmail, IsOptional, IsString, IsUUID } from 'class-validator'; @Expose() @@ -20,6 +21,7 @@ export class UserDto { @IsOptional() @IsString() @Expose() + @Transform(({ value }) => value ?? null) avatarUrl!: string | null; @ApiProperty({ @@ -32,5 +34,10 @@ export class UserDto { @IsOptional() @IsEmail() @Expose() + @Transform(({ obj, options }) => { + return options?.groups?.includes('includeEmail') && obj.email + ? obj.email + : null; + }) email!: string | null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb30472dc..3496d4bb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: backend: dependencies: + '@aws-sdk/client-s3': + specifier: 3.1010.0 + version: 3.1010.0 '@aws-sdk/client-sts': specifier: 3.1005.0 version: 3.1005.0 @@ -272,9 +275,6 @@ importers: specifier: ^6.1.4 version: 6.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) devDependencies: - '@aws-sdk/client-s3': - specifier: 3.1010.0 - version: 3.1010.0 '@jest/globals': specifier: ^30.2.0 version: 30.2.0