From 40ac77c4f32eba5d075d524e7eef209b6f313c34 Mon Sep 17 00:00:00 2001 From: Jose Semedo <jose.semedo@cern.ch> Date: Thu, 17 Mar 2022 17:40:35 +0100 Subject: [PATCH] [#85] Swagger notifications --- src/app.ts | 6 +- src/controllers/devices/controller.ts | 232 ++++++------- src/controllers/devices/dto.ts | 316 ++++++++++++++---- src/controllers/notifications-controller.ts | 105 ------ src/controllers/notifications/controller.ts | 106 ++++++ src/controllers/notifications/dto.ts | 278 +++++++++++++++ src/models/channel.ts | 2 +- src/models/notification-enums.ts | 22 ++ src/models/notification.ts | 35 +- src/models/preference.ts | 42 +-- src/services/api-key-service.ts | 2 +- src/services/devices-service.ts | 14 +- src/services/impl/devices-service-impl.ts | 67 ++-- src/services/impl/devices/get-user-devices.ts | 41 ++- src/services/impl/devices/test-user-device.ts | 2 +- .../impl/devices/update-user-device.ts | 54 +-- .../impl/notifications-service-impl.ts | 56 ++-- src/services/impl/notifications/get-by-id.ts | 7 +- .../impl/notifications/send-notification.ts | 48 +-- .../notifications/update-user-notification.ts | 68 ++-- src/services/notifications-service.ts | 21 +- src/utils/status-codes.ts | 116 +++++++ 22 files changed, 1085 insertions(+), 555 deletions(-) delete mode 100644 src/controllers/notifications-controller.ts create mode 100644 src/controllers/notifications/controller.ts create mode 100644 src/controllers/notifications/dto.ts create mode 100644 src/models/notification-enums.ts create mode 100644 src/utils/status-codes.ts diff --git a/src/app.ts b/src/app.ts index 2874037d..85857e02 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,8 @@ import { AuthorizationChecker } from './middleware/authorizationChecker'; import { Configuration } from './config/configuration'; import * as sentry from './log/sentry'; import * as swaggerUiExpress from 'swagger-ui-express'; -import { Controller } from './controllers/devices/controller'; +import { DevicesController } from './controllers/devices/controller'; +import { NotificationsController } from './controllers/notifications/controller'; import * as fs from 'fs'; import * as https from 'https'; @@ -18,7 +19,7 @@ Configuration.load(); const { defaultMetadataStorage } = require('class-transformer/cjs/storage'); const routingControllersOptions = { - controllers: [Controller], + controllers: [DevicesController, NotificationsController], }; // Check ROLES are defined in ENV for controller Authorizations @@ -96,6 +97,7 @@ const swaggerOptions = { // Use the app client-id instead, tha tmus tbe configure implicit but not public (see above) clientId: process.env.OAUTH_CLIENT_ID, }, + filter: true, }, }; diff --git a/src/controllers/devices/controller.ts b/src/controllers/devices/controller.ts index cd609bee..e8c9dda0 100644 --- a/src/controllers/devices/controller.ts +++ b/src/controllers/devices/controller.ts @@ -1,124 +1,126 @@ import { - Authorized, - BodyParam, - Delete, - Get, - JsonController, - OnUndefined, - Param, Patch, - Post, - Put, - Req, -} from "routing-controllers"; -import {ServiceFactory} from "../../services/services-factory"; + Authorized, + Body, + Delete, + Get, + JsonController, + OnUndefined, + Param, + Patch, + Post, + Req, +} from 'routing-controllers'; +import { ServiceFactory } from '../../services/services-factory'; -import {DevicesServiceInterface} from "../../services/devices-service"; -import {OpenAPI, ResponseSchema} from "routing-controllers-openapi"; -import {DeviceRequest, DeviceResponse, GetDevicesResponse} from "./dto"; +import { DevicesServiceInterface } from '../../services/devices-service'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { DeviceRequest, DeviceResponse, DeviceValuesRequest, GetDevicesResponse } from './dto'; +import { StatusCodes, StatusCodeDescriptions } from '../../utils/status-codes'; -@JsonController("/devices") -export class Controller { - devicesService: DevicesServiceInterface = ServiceFactory.getDevicesService(); +@JsonController('/devices') +export class DevicesController { + devicesService: DevicesServiceInterface = ServiceFactory.getDevicesService(); - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @OpenAPI({ - description: "Creates a new device.", - operationId: "createDevice", - //security: [{oauth2: []}], Not usable through swagger UI - responses: { - '200': {description: "OK"}, - '401': {description: "Unauthorized"}, - '403': {description: "Forbidden"} - }, - }) - @ResponseSchema(DeviceResponse, {description: 'Created device'}) - @Post() - createUserDevice( - @Req() req, - @BodyParam("device") device: DeviceRequest - ) { - return this.devicesService.createUserDevice(device, req.authorizationBag); - } + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: 'Creates a new user device as specified in the body param. For internal use.', + description: 'Creates a new user device.', + operationId: 'createDevice', + //security: [{oauth2: []}], Not usable through swagger UI + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + }, + }) + @ResponseSchema(DeviceResponse, { description: 'Created device' }) + @Post() + createUserDevice(@Req() req, @Body() device: DeviceRequest): Promise<DeviceResponse> { + return this.devicesService.createUserDevice(device, req.authorizationBag); + } - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @OpenAPI({ - description: "Lists all of the user's devices.", - operationId: "listDevices", - security: [{oauth2: []}], - responses: { - '200': {description: "OK"}, - '401': {description: "Unauthorized"}, - '403': {description: "Forbidden"} - }, - }) - @ResponseSchema(GetDevicesResponse, { - description: 'A list of user devices' - }) - @Get() - getUserDevices(@Req() req) { - return this.devicesService.getUserDevices(req.authorizationBag); - } + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: "List user's devices", + description: "Lists all of the user's devices.", + operationId: 'listDevices', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + }, + }) + @ResponseSchema(GetDevicesResponse, { + description: 'A list of user devices', + }) + @Get() + getUserDevices(@Req() req): Promise<GetDevicesResponse> { + return this.devicesService.getUserDevices(req.authorizationBag); + } - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @OpenAPI({ - description: "Delete user's device with with provided ID.", - operationId: "deleteDevice", - security: [{oauth2: []}], - responses: { - '204': {description: "No Content"}, - '400': {description: "Bad Request"}, - '401': {description: "Unauthorized"}, - '403': {description: "Forbidden"} - }, - }) - @Delete("/:deviceId") - @OnUndefined(204) - deleteUserDeviceById( - @Req() req, - @Param("deviceId") deviceId: string - ) { - return this.devicesService.deleteUserDeviceById(deviceId, req.authorizationBag); - } + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + description: "Delete user's device with with provided device id.", + operationId: 'deleteDevice', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.NoContent]: StatusCodeDescriptions[StatusCodes.NoContent], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.BadRequest]: StatusCodeDescriptions[StatusCodes.BadRequest], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + }, + }) + @Delete('/:deviceId') + @OnUndefined(204) + deleteUserDeviceById(@Req() req, @Param('deviceId') deviceId: string): Promise<string> { + return this.devicesService.deleteUserDeviceById(deviceId, req.authorizationBag); + } - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @OpenAPI({ - description: "Triggers a test notification on the device with the provided ID.", - operationId: "testDevice", - security: [{oauth2: []}], - responses: { - '204': {description: "No Content"}, - '400': {description: "Bad Request"}, - '401': {description: "Unauthorized"}, - '403': {description: "Forbidden"} - }, - }) - @Post("/:deviceId") - @OnUndefined(204) - tryBrowserPushNotification( - @Req() req, - @Param("deviceId") deviceId: string - ) { - return this.devicesService.tryBrowserPushNotification(deviceId, req.authorizationBag); - } + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: 'Send test notification', + description: "Sends a test notification to the user's device with the provided device id.", + operationId: 'testDevice', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.NoContent]: StatusCodeDescriptions[StatusCodes.NoContent], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.BadRequest]: StatusCodeDescriptions[StatusCodes.BadRequest], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + }, + }) + @Post('/:deviceId') + @OnUndefined(204) + tryBrowserPushNotification(@Req() req, @Param('deviceId') deviceId: string): Promise<void> { + return this.devicesService.tryBrowserPushNotification(deviceId, req.authorizationBag); + } - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @OpenAPI({ - description: "Updates the device with the provided ID, using the provided name and info.", - operationId: "updateDevice", - security: [{oauth2: []}], - responses: { - '200': {description: "OK"}, - '401': {description: "Unauthorized"}, - '403': {description: "Forbidden"} - }, - }) - @Patch("/:deviceId") - updateUserDeviceById( - @Req() req, - @Param("deviceId") deviceId: string, - @BodyParam("name") name: string, - @BodyParam("info") info: string - ) { - return this.devicesService.updateUserDeviceById(deviceId, req.authorizationBag, name, info); - } + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: 'Update device.', + description: 'Updates the device with the provided device ID, using the provided name and info.', + operationId: 'updateDevice', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + }, + }) + @ResponseSchema(DeviceResponse, { + description: 'The updated device.', + }) + @Patch('/:deviceId') + updateUserDeviceById( + @Req() req, + @Param('deviceId') deviceId: string, + @Body() newDeviceValues: DeviceValuesRequest, + ): Promise<DeviceResponse> { + return this.devicesService.updateUserDeviceById(deviceId, req.authorizationBag, newDeviceValues); + } } diff --git a/src/controllers/devices/dto.ts b/src/controllers/devices/dto.ts index c9bc0a74..9eb6f77c 100644 --- a/src/controllers/devices/dto.ts +++ b/src/controllers/devices/dto.ts @@ -1,77 +1,263 @@ -import {IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateNested} from "class-validator"; -import {Device, DeviceSubType, DeviceType} from "../../models/device"; -import {Type} from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Device, DeviceSubType, DeviceType } from '../../models/device'; +import { JSONSchema } from 'class-validator-jsonschema'; +import { Type } from 'class-transformer'; +import { v4 } from 'uuid'; -export class GetDevicesResponse { - @ValidateNested({each: true}) - @Type(() => DeviceResponse) - userDevices: DeviceResponse[]; +@JSONSchema({ + description: 'Device json return.', + example: { + name: 'Mac Firefox', + id: v4(), + info: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0', + type: DeviceType.BROWSER, + subType: DeviceSubType.OTHER, + uuid: v4(), + token: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }, +}) +export class DeviceResponse { + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Name of the returned device.', + example: 'Mac Firefox', + }) + name: string; + + @IsUUID() + @IsNotEmpty() + @JSONSchema({ + description: 'Id of the returned device.', + example: v4(), + }) + id: string; + + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Info about the returned device.', + example: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0', + }) + info: string; + + @IsNotEmpty() + @IsEnum(DeviceType) + @JSONSchema({ + description: 'Type of the returned device.', + example: DeviceType.BROWSER, + }) + type: DeviceType; + + @IsOptional() + @IsEnum(DeviceSubType) + @JSONSchema({ + description: 'Subtype of the returned device.', + example: DeviceSubType.OTHER, + }) + subType: DeviceSubType; + + @IsUUID() + @IsOptional() + @JSONSchema({ + description: 'Apple device identifier needed for browser registration management in Apple cloud.', + example: v4(), + }) + uuid: string; - constructor(list: DeviceResponse[]) { - this.userDevices = list; - } + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Target identifier to use when sending a notification.', + example: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }) + token: string; + + constructor(device: Device) { + this.id = device.id; + this.name = device.name; + this.info = device.info; + this.type = device.type; + this.subType = device.subType; + this.uuid = device.uuid; + this.token = device.token; + } } +@JSONSchema({ + description: 'Json response with list of devices.', + example: { + userDevices: [ + new DeviceResponse( + new Device({ + name: 'Mattermost', + id: v4(), + info: 'useremail@cern.ch', + type: DeviceType.APP, + subType: DeviceSubType.MATTERMOST, + uuid: null, + token: 'useremail@cern.ch', + }), + ), + new DeviceResponse( + new Device({ + name: 'useremail@cern.ch', + id: v4(), + info: 'useremail@cern.ch', + type: DeviceType.MAIL, + subType: null, + uuid: null, + token: 'useremail@cern.ch', + }), + ), + new DeviceResponse( + new Device({ + name: 'Max Firefox', + id: v4(), + info: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0.', + type: DeviceType.BROWSER, + subType: DeviceSubType.OTHER, + uuid: v4(), + token: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }), + ), + ], + }, +}) +export class GetDevicesResponse { + @ValidateNested({ each: true }) + @Type(() => DeviceResponse) + @JSONSchema({ + description: 'Device list ', + example: [ + new DeviceResponse( + new Device({ + name: 'Mattermost', + id: v4(), + info: 'useremail@cern.ch', + type: DeviceType.APP, + subType: DeviceSubType.MATTERMOST, + uuid: null, + token: 'useremail@cern.ch', + }), + ), + new DeviceResponse( + new Device({ + name: 'useremail@cern.ch', + id: v4(), + info: 'useremail@cern.ch', + type: DeviceType.MAIL, + subType: null, + uuid: null, + token: 'useremail@cern.ch', + }), + ), + new DeviceResponse( + new Device({ + name: 'Max Firefox', + id: v4(), + info: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0.', + type: DeviceType.BROWSER, + subType: DeviceSubType.OTHER, + uuid: v4(), + token: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }), + ), + ], + }) + userDevices: DeviceResponse[]; + constructor(list: DeviceResponse[]) { + this.userDevices = list; + } +} +@JSONSchema({ + description: 'New Device json input.', + example: { + name: 'Mac Firefox', + info: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0.', + type: DeviceType.BROWSER, + subType: DeviceSubType.OTHER, + uuid: v4(), + token: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }, +}) export class DeviceRequest { - @IsNotEmpty() - name: string; + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Name of the device to be created.', + example: 'Mac Firefox', + }) + name: string; - @IsNotEmpty() - @IsString() - info: string; + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Info about the returned device.', + example: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0', + }) + info: string; - @IsNotEmpty() - @IsEnum(DeviceType) - type: DeviceType; + @IsNotEmpty() + @IsEnum(DeviceType) + @JSONSchema({ + description: 'Type of the returned device.', + example: DeviceType.BROWSER, + }) + type: DeviceType; - @IsOptional() - @IsEnum(DeviceSubType) - subType: DeviceSubType; + @IsOptional() + @IsEnum(DeviceSubType) + @JSONSchema({ + description: 'Subtype of the returned device.', + example: DeviceSubType.OTHER, + }) + subType: DeviceSubType; - @IsUUID() - @IsOptional() - uuid: string; + @IsUUID() + @IsOptional() + @JSONSchema({ + description: 'Apple device identifier needed for browser registration management in Apple cloud.', + example: v4(), + }) + uuid: string; - @IsNotEmpty() - @IsString() - token: string; + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Target identifier to use when sending a notification.', + example: + '{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/asfhijbsaSDArandomuuidasyrigbdsjyc","expirationTime":null,"keys":{"auth":"ahahyouwish","p256dh":"nopenopenope"}}', + }) + token: string; } -// noinspection DuplicatedCode -export class DeviceResponse { - @IsNotEmpty() - name: string; - - @IsUUID() - id: string; - - @IsNotEmpty() - @IsString() - info: string; - - @IsNotEmpty() - @IsEnum(DeviceType) - type: DeviceType; - - @IsOptional() - @IsEnum(DeviceSubType) - subType: DeviceSubType; - - @IsUUID() - @IsOptional() - uuid: string; - - @IsNotEmpty() - @IsString() - token: string; - - constructor(device: Device) { - this.id = device.id; - this.name = device.name; - this.info = device.info; - this.type = device.type; - this.subType = device.subType; - this.uuid = device.uuid; - this.token = device.token; - } -} \ No newline at end of file +@JSONSchema({ + description: 'New Device json input.', + example: { + name: 'Mac Firefox', + info: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0.', + }, +}) +export class DeviceValuesRequest { + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Name the device is to be changed to.', + example: 'Mac Firefox', + }) + name: string; + + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Info the device is to be changed to.', + example: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11) Firefox/97.0', + }) + info: string; +} diff --git a/src/controllers/notifications-controller.ts b/src/controllers/notifications-controller.ts deleted file mode 100644 index 7aab057c..00000000 --- a/src/controllers/notifications-controller.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - Get, - Post, - JsonController, - BodyParam, - Authorized, - Req, - Body, - Param, - QueryParams, - BadRequestError, - Put, - OnUndefined, - UnauthorizedError, -} from 'routing-controllers'; -import { ServiceFactory } from '../services/services-factory'; -import { NotificationsService } from '../services/notifications-service'; -import { Notification } from '../models/notification'; -import { API_KEY_ACCESS_ROLE } from '../middleware/authorizationChecker'; - -@JsonController('/notifications') -export class NotificationsController { - notificationsService: NotificationsService = - ServiceFactory.getNotificationsService(); - - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @Get('/:id') - getById( - @Req() req, - @Param('id') notificationId: string, - @QueryParams() query, - ) { - return this.notificationsService.getById( - notificationId, - query, - req.authorizationBag, - ); - } - - @Authorized([ - process.env.INTERNAL_ROLE, - process.env.VIEWER_ROLE, - API_KEY_ACCESS_ROLE, - ]) - @Post() - sendNotification( - @Body() body, - @BodyParam('notification') notification: Notification, - @Req() req, - ) { - if (!notification) - throw new BadRequestError( - `The request body does not include a "notification" object. Body: ${JSON.stringify( - body, - )}`, - ); - - return this.notificationsService.sendNotification( - notification, - req.authorizationBag, - ); - } - - @OnUndefined(201) - @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) - @Put('/:id/retry') - retryNotification( - @Param('id') notificationId: string, - @Req() req, - ): Promise<void> { - if ( - !process.env.EXPOSE_UNAUTHENTICATED_ROUTES || - process.env.EXPOSE_UNAUTHENTICATED_ROUTES === 'False' - ) { - throw new UnauthorizedError('Unauthenticated route is not enabled'); - } - - return this.notificationsService.retryNotification(notificationId, null); - } - - // TODO: Fix calls from mail_gateway consumer by adding a bearer token or restricting on - @Post('/unauthenticated') - sendNotificationWithoutAuth( - @Body() body, - @BodyParam('notification') notification: Notification, - @Req() req, - ) { - if ( - !process.env.EXPOSE_UNAUTHENTICATED_ROUTES || - process.env.EXPOSE_UNAUTHENTICATED_ROUTES === 'False' - ) { - throw new UnauthorizedError('Unauthenticated route is not enabled'); - } - - if (!notification) - throw new BadRequestError( - `The request body does not include a "notification" object. Body: ${JSON.stringify( - body, - )}`, - ); - // notification.from = req.service; - - return this.notificationsService.sendNotification(notification, null); - } -} diff --git a/src/controllers/notifications/controller.ts b/src/controllers/notifications/controller.ts new file mode 100644 index 00000000..8e03ef07 --- /dev/null +++ b/src/controllers/notifications/controller.ts @@ -0,0 +1,106 @@ +import { + Get, + Post, + JsonController, + Authorized, + Req, + Param, + Put, + OnUndefined, + ForbiddenError, + Body, +} from 'routing-controllers'; +import { ServiceFactory } from '../../services/services-factory'; +import { NotificationsService } from '../../services/notifications-service'; +import { GetNotificationResponse, SendNotificationRequest } from './dto'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { API_KEY_ACCESS_ROLE } from '../../middleware/authorizationChecker'; +import { StatusCodeDescriptions, StatusCodes } from '../../utils/status-codes'; + +@JsonController('/notifications') +export class NotificationsController { + notificationsService: NotificationsService = ServiceFactory.getNotificationsService(); + + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: 'Returns the Notification with the provided notification id.', + description: 'This endpoint returns the notification with the provided notification id, if requester has access.', + operationId: 'getNotification', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + }, + }) + @ResponseSchema(GetNotificationResponse, { description: 'Notification requested.' }) + @Get('/:id') + getById(@Req() req, @Param('id') notificationId: string): Promise<GetNotificationResponse> { + return this.notificationsService.getById(notificationId, req.authorizationBag); + } + + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE, API_KEY_ACCESS_ROLE]) + @OpenAPI({ + summary: 'Send a Notification.', + description: 'Sends the Notification to the Channel with the channel id provided.', + operationId: 'sendNotification', + security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.BadRequest]: StatusCodeDescriptions[StatusCodes.BadRequest], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + }, + }) + @ResponseSchema(GetNotificationResponse, { description: 'Notification sent.' }) + @Post() + sendNotification(@Body() notification: SendNotificationRequest, @Req() req): Promise<GetNotificationResponse> { + return this.notificationsService.sendNotification(notification, req.authorizationBag); + } + + @OnUndefined(201) + @Authorized([process.env.INTERNAL_ROLE, process.env.VIEWER_ROLE]) + @OpenAPI({ + summary: 'Retries to send Notification with provided notification id. For internal use.', + description: 'Retries to send Notification with provided notification id.', + operationId: 'retryNotification', + //security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.NotFound]: StatusCodeDescriptions[StatusCodes.NotFound], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + }, + }) + @Put('/:id/retry') + retryNotification(@Param('id') notificationId: string, @Req() req): Promise<void> { + if (!process.env.EXPOSE_UNAUTHENTICATED_ROUTES || process.env.EXPOSE_UNAUTHENTICATED_ROUTES === 'False') { + throw new ForbiddenError('Unauthenticated route is not enabled'); + } + return this.notificationsService.retryNotification(notificationId, null); + } + + // TODO: Fix calls from mail_gateway consumer by adding a bearer token or restricting on + @OpenAPI({ + summary: 'Sends Notification without the need for Auth. Internal use only.', + description: 'Sends a Notification without the need of Auth.', + operationId: 'sentNotificationUnauthenticated', + //security: [{ oauth2: [] }], + responses: { + [StatusCodes.OK]: StatusCodeDescriptions[StatusCodes.OK], + [StatusCodes.BadRequest]: StatusCodeDescriptions[StatusCodes.BadRequest], + [StatusCodes.Forbidden]: StatusCodeDescriptions[StatusCodes.Forbidden], + [StatusCodes.Unauthorized]: StatusCodeDescriptions[StatusCodes.Unauthorized], + }, + }) + @ResponseSchema(GetNotificationResponse, { description: 'Notification sent.' }) + @Post('/unauthenticated') + sendNotificationWithoutAuth(@Body() notification: SendNotificationRequest): Promise<GetNotificationResponse> { + if (!process.env.EXPOSE_UNAUTHENTICATED_ROUTES || process.env.EXPOSE_UNAUTHENTICATED_ROUTES === 'False') { + throw new ForbiddenError('Unauthenticated route is not enabled'); + } + return this.notificationsService.sendNotification(notification, null); + } +} diff --git a/src/controllers/notifications/dto.ts b/src/controllers/notifications/dto.ts new file mode 100644 index 00000000..d5fd540f --- /dev/null +++ b/src/controllers/notifications/dto.ts @@ -0,0 +1,278 @@ +import { + IsBoolean, + IsDate, + IsDateString, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxDate, + MinDate, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { Notification } from '../../models/notification'; +import { PriorityLevel, Source } from '../../models/notification-enums'; +import { JSONSchema } from 'class-validator-jsonschema'; +import { v4 } from 'uuid'; + +@JSONSchema({ + description: 'Notification json return', + example: { + id: v4(), + body: '<p>My body</p>', + description: 'Notification description.', + summary: 'My notification', + target: v4(), + sendAt: new Date(), + sentAt: new Date(), + priority: PriorityLevel.NORMAL, + link: 'https://notifications.web.cern.ch/', + imgUrl: 'https://home.cern/sites/default/files/logo/cern-logo.png', + source: Source.api, + }, +}) +export class GetNotificationResponse { + @IsString() + @IsNotEmpty() + @IsUUID('4') + @JSONSchema({ + description: 'Notification id or the returned notification.', + example: v4(), + }) + id: string; + + @IsString() + @IsNotEmpty() + @JSONSchema({ + description: 'Body of the returned notification.', + example: '<p>My body</p>', + }) + body: string; + + @IsDateString() + @MinDate(new Date()) + @JSONSchema({ + description: 'Date the returned notification is to be sent at.', + example: new Date(), + }) + sendAt: Date; + + @IsDateString() + @MaxDate(new Date()) + @JSONSchema({ + description: 'Date the returned notification was sent.', + example: new Date(), + }) + sentAt: Date; + + @IsString() + @JSONSchema({ + description: 'Specify a target url to redirect when clicked.', + example: 'https://notifications.web.cern.ch/', + }) + link: string; + + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Summary of the returned notification.', + example: 'My notification', + }) + summary: string; + + @IsString() + @JSONSchema({ + description: 'Specify an image url to display as preview when possible, eg. push notifications', + example: 'https://home.cern/sites/default/files/logo/cern-logo.png', + }) + imgUrl: string; + + @IsEnum(Source) + @JSONSchema({ + description: 'Internal use only. Will be ignored.', + example: Source.api, + default: Source.api, + }) + source: Source; + + constructor(notification: Notification) { + this.id = notification.id; + this.body = notification.body; + this.sendAt = notification.sendAt; + this.sentAt = notification.sentAt; + this.link = notification.link; + this.summary = notification.summary; + this.imgUrl = notification.imgUrl; + this.source = notification.source; + } +} + +@JSONSchema({ + description: 'New notification json input', + example: { + body: '<p>My body</p>', + summary: 'My notification', + target: v4(), + priority: PriorityLevel.NORMAL, + link: 'https://notifications.web.cern.ch/', + imgUrl: 'https://home.cern/sites/default/files/logo/cern-logo.png', + sendAt: new Date(), + targetUsers: [{ email: 'username' }], + targetGroups: [{ groupIdentifier: 'user-group' }], + intersection: true, + source: Source.api, + }, +}) +export class SendNotificationRequest { + @IsString() + @IsNotEmpty() + @JSONSchema({ + description: 'Body of the notification to be sent. Supports raw text and HTML.', + example: '<p>My body</p>', + }) + body: string; + + @IsNotEmpty() + @IsUUID('4') + @JSONSchema({ + description: 'Channel ID of the channel to where the notification is to be sent.', + example: v4(), + }) + target: string; + + @JSONSchema({ + description: + 'Supports mixed strings emails and group names (but degraded performance therefore not the ' + + 'recommended option). Requires private set to "true".', + example: ['user@cern.ch', 'my-group'], + }) + @IsString({ each: true }) + @IsOptional() + targetData: string[]; + + @IsString() + @IsOptional() + @JSONSchema({ description: 'Internal use only. Will be ignored.' }) + sender: string; + + @IsNotEmpty() + @IsString() + @JSONSchema({ + description: 'Summary of the notification to be sent.', + example: 'My notification', + }) + summary: string; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => SendNotificationRequestGroup) + @JSONSchema({ + description: 'Groups of targeted users. Requires private set to "true".', + example: [{ groupIdentifier: 'user-group' }, { groupIdentifier: 'another-group' }], + }) + targetGroups: SendNotificationRequestGroup[]; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => SendNotificationRequestUser) + @JSONSchema({ + description: 'Emails or logins of targeted users. Requires private set to "true".', + example: [{ email: 'username' }, { email: 'user@cern.ch' }], + }) + targetUsers: SendNotificationRequestUser[]; + + @IsString() + @IsOptional() + @JSONSchema({ + description: 'Specify an image url to display as preview when possible, eg. push notifications', + example: 'https://home.cern/sites/default/files/logo/cern-logo.png', + }) + imgUrl: string; + + @IsNotEmpty() + @IsEnum(PriorityLevel) + @JSONSchema({ + description: 'Priority with which the notification is sent.', + example: PriorityLevel.NORMAL, + default: PriorityLevel.NORMAL, + }) + priority: PriorityLevel; + + @IsEnum(Source) + @JSONSchema({ + description: 'Internal use only. Will be ignored.', + example: Source.api, + default: Source.api, + }) + source: Source; + + @IsString() + @IsOptional() + @JSONSchema({ + description: 'Specify a target url to redirect when clicked.', + example: 'https://notifications.web.cern.ch/', + }) + link: string; + + @IsBoolean() + @IsOptional() + @JSONSchema({ + description: 'Enable to send targeted notifications.', + example: false, + default: false, + }) + private: boolean; + + @IsBoolean() + @IsOptional() + @JSONSchema({ + description: 'Enable to send only to intersection between channel members/groups and targeted group.', + example: false, + default: false, + }) + intersection: boolean; + + @IsOptional() + @IsDateString() + @JSONSchema({ + description: 'Send notification at this date.', + example: new Date(), + default: null, + }) + sendAt: Date; +} + +@JSONSchema({ + description: 'Json object with target users.', + example: { + email: 'username', + }, +}) +class SendNotificationRequestUser { + @IsString() + @IsNotEmpty() + @JSONSchema({ + description: 'Users to whom the notification is to be sent.', + example: '"email@cern.ch" or "username"', + }) + email: string; +} + +@JSONSchema({ + description: 'Json object with target groups.', + example: { + groupIdentifier: 'user-group', + }, +}) +class SendNotificationRequestGroup { + @IsString() + @IsNotEmpty() + @JSONSchema({ + description: 'Groups to which the notification is to be sent.', + example: 'user-group', + }) + groupIdentifier: string; +} diff --git a/src/models/channel.ts b/src/models/channel.ts index 0c98b39e..e8874fa6 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -326,7 +326,7 @@ export class Channel extends ApiKeyObject { // Checks if user has authorization to send Notification to the channel // via Form/API - async canSendByForm(authorizationBag: AuthorizationBag, targetedNotification: boolean = false) { + async canSendByForm(authorizationBag: AuthorizationBag, targetedNotification = false) { // No permission to send by Form set if (!this.submissionByForm) return false; diff --git a/src/models/notification-enums.ts b/src/models/notification-enums.ts new file mode 100644 index 00000000..c18f8cf0 --- /dev/null +++ b/src/models/notification-enums.ts @@ -0,0 +1,22 @@ +const SendDateFormat = 'YYYY-MM-DDTHH:mm:ss.sssZ'; +export { SendDateFormat }; + +export enum PriorityLevel { + CRITICAL = 'CRITICAL', + IMPORTANT = 'IMPORTANT', + NORMAL = 'NORMAL', + LOW = 'LOW', +} + +export enum Source { + email = 'EMAIL', + web = 'WEB', + api = 'API', +} + +export enum Times { + morning = 9, + lunch = 13, + afternoon = 17, + night = 21, +} diff --git a/src/models/notification.ts b/src/models/notification.ts index cd17027a..0c0a691a 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -4,29 +4,7 @@ import { Channel } from './channel'; import { User } from './user'; import { Group } from './group'; import { AuthorizationBag } from './authorization-bag'; - -export enum PriorityLevel { - CRITICAL = 'CRITICAL', - IMPORTANT = 'IMPORTANT', - NORMAL = 'NORMAL', - LOW = 'LOW', -} - -export enum Source { - email = 'EMAIL', - web = 'WEB', - api = 'API', -} - -export enum Times { - morning = 9, - lunch = 13, - afternoon = 17, - night = 21, -} - -const SendDateFormat = 'YYYY-MM-DDTHH:mm:ss.sssZ'; -export { SendDateFormat }; +import { Source, PriorityLevel, Times } from './notification-enums'; @Entity({ name: 'Notifications' }) export class Notification { @@ -52,19 +30,16 @@ export class Notification { @ManyToOne(type => Channel, channel => channel.notifications) target: Channel; - @Column('simple-array', { nullable: true }) - tags: string[]; - @Column({ nullable: true }) link: string; @Column({ nullable: true }) summary: string; - @Column({ nullable: true }) contentType: string; @Column({ nullable: true }) imgUrl: string; @Column({ nullable: true }) sender: string; + @Column({ nullable: true }) contentType: string; @Column({ enum: PriorityLevel, default: PriorityLevel.NORMAL }) priority: PriorityLevel; @Column({ enum: Source, default: Source.api }) - source: string; + source: Source; @Column({ default: false }) private: boolean; @@ -91,10 +66,8 @@ export class Notification { this.sendAt = notification.sendAt; this.sentAt = notification.sendAt ? null : new Date(); this.users = notification.users || []; - // this.tags = notification.tags; this.link = notification.link; this.summary = notification.summary; - // this.contentType = notification.contentType; this.imgUrl = notification.imgUrl; this.priority = notification.priority; this.source = notification.source; @@ -113,7 +86,7 @@ export class Notification { return true; } if (!this.targetGroups) return false; - for (let g of this.targetGroups) { + for (const g of this.targetGroups) { if (await g.isMember(authorizationBag.user)) { return true; } diff --git a/src/models/preference.ts b/src/models/preference.ts index b3f05f63..d33504eb 100644 --- a/src/models/preference.ts +++ b/src/models/preference.ts @@ -12,7 +12,7 @@ import { } from 'typeorm'; import { User } from './user'; import { Channel } from './channel'; -import { PriorityLevel } from './notification'; +import { PriorityLevel } from './notification-enums'; import { Device } from './device'; import { BadRequestError } from 'routing-controllers'; @@ -63,9 +63,7 @@ export class Preference { async priorityToUpperCase(): Promise<void> { if (!this.notificationPriority) return; for (var i = 0; i < this.notificationPriority.length; i++) - this.notificationPriority[i] = this.notificationPriority[ - i - ].toUpperCase() as PriorityLevel; + this.notificationPriority[i] = this.notificationPriority[i].toUpperCase() as PriorityLevel; } @Column({ @@ -95,8 +93,7 @@ export class Preference { @BeforeInsert() @AfterLoad() async bcScheduledTime(): Promise<void> { - if (this.type === 'DAILY' && !this.scheduledTime) - this.scheduledTime = allowedScheduledTimes[0]; + if (this.type === 'DAILY' && !this.scheduledTime) this.scheduledTime = allowedScheduledTimes[0]; } @ManyToMany(type => Channel, { @@ -136,48 +133,31 @@ export class Preference { } if (this.notificationPriority.length === 0) { - throw new BadRequestError( - 'Invalid Priority: at least one Priority is required', - ); + throw new BadRequestError('Invalid Priority: at least one Priority is required'); } if (this.rangeStart === this.rangeEnd && this.rangeStart && this.rangeEnd) { - throw new BadRequestError( - 'Invalid Preference Time Range: start time and end time must be different', - ); - } else if ( - (this.rangeStart && !this.rangeEnd) || - (this.rangeEnd && !this.rangeStart) - ) { - throw new BadRequestError( - "Invalid Preference Time Range: start time and end time can't be empty", - ); + throw new BadRequestError('Invalid Preference Time Range: start time and end time must be different'); + } else if ((this.rangeStart && !this.rangeEnd) || (this.rangeEnd && !this.rangeStart)) { + throw new BadRequestError("Invalid Preference Time Range: start time and end time can't be empty"); } if ( - (this.type === 'DAILY' || - this.type === 'WEEKLY' || - this.type === 'MONTHLY') && + (this.type === 'DAILY' || this.type === 'WEEKLY' || this.type === 'MONTHLY') && !allowedScheduledTimes.includes(this.scheduledTime) ) { - throw new BadRequestError( - 'Invalid Scheduled Time: scheduled time can be 09:00, 13:00, 17:00 or 21:00', - ); + throw new BadRequestError('Invalid Scheduled Time: scheduled time can be 09:00, 13:00, 17:00 or 21:00'); } if ( (this.type === 'WEEKLY' || this.type === 'MONTHLY') && !Object.values(ScheduledDay).includes(this.scheduledDay) ) { - throw new BadRequestError( - 'Invalid Scheduled Day: scheduled day can be 0, 1, 2, 3, 4, 5, 6', - ); + throw new BadRequestError('Invalid Scheduled Day: scheduled day can be 0, 1, 2, 3, 4, 5, 6'); } if (this.devices.length === 0) { - throw new BadRequestError( - 'Invalid Target Device: at least one Target Device is required', - ); + throw new BadRequestError('Invalid Target Device: at least one Target Device is required'); } return true; diff --git a/src/services/api-key-service.ts b/src/services/api-key-service.ts index b58c84df..6df2f7a2 100644 --- a/src/services/api-key-service.ts +++ b/src/services/api-key-service.ts @@ -1,7 +1,7 @@ import { AuthorizationBag } from '../models/authorization-bag'; export interface ApiKeyService { - generateApiKey(id: string, authorizationBag: AuthorizationBag): Promise<string>; + generateApiKey(id: string, authorizationBag: AuthorizationBag): Promise<String>; verifyAPIKey(id: string, key: string): Promise<boolean>; } diff --git a/src/services/devices-service.ts b/src/services/devices-service.ts index b3408b56..994c66c2 100644 --- a/src/services/devices-service.ts +++ b/src/services/devices-service.ts @@ -1,14 +1,18 @@ -import {DeviceRequest, DeviceResponse} from "../controllers/devices/dto"; -import { AuthorizationBag } from "../models/authorization-bag"; +import { DeviceRequest, DeviceResponse, DeviceValuesRequest, GetDevicesResponse } from '../controllers/devices/dto'; +import { AuthorizationBag } from '../models/authorization-bag'; export interface DevicesServiceInterface { createUserDevice(device: DeviceRequest, authorizationBag: AuthorizationBag): Promise<DeviceResponse>; - getUserDevices(authorizationBag: AuthorizationBag): Promise<DeviceResponse[]>; + getUserDevices(authorizationBag: AuthorizationBag): Promise<GetDevicesResponse>; deleteUserDeviceById(deviceId: string, authorizationBag: AuthorizationBag): Promise<string>; - tryBrowserPushNotification(deviceId: string, authorizationBag: AuthorizationBag): Promise<string>; + tryBrowserPushNotification(deviceId: string, authorizationBag: AuthorizationBag): Promise<void>; - updateUserDeviceById(deviceId: string, authorizationBag: AuthorizationBag, name: string, info: string): Promise<DeviceResponse>; + updateUserDeviceById( + deviceId: string, + authorizationBag: AuthorizationBag, + newDeviceValues: DeviceValuesRequest, + ): Promise<DeviceResponse>; } diff --git a/src/services/impl/devices-service-impl.ts b/src/services/impl/devices-service-impl.ts index c28d5c2a..8c363984 100644 --- a/src/services/impl/devices-service-impl.ts +++ b/src/services/impl/devices-service-impl.ts @@ -1,61 +1,36 @@ -import { AbstractService } from "./abstract-service"; -import { DevicesServiceInterface } from "../devices-service"; -import { Device } from "../../models/device"; -import { CreateUserDevice } from "./devices/create-user-device"; -import { GetUserDevices } from "./devices/get-user-devices"; -import { DeleteUserDevice } from "./devices/delete-user-device"; -import { TestUserDevice } from "./devices/test-user-device"; -import { UpdateUserDevice } from "./devices/update-user-device"; -import { AuthorizationBag } from "../../models/authorization-bag"; -import {DeviceRequest, DeviceResponse} from "../../controllers/devices/dto"; +import { AbstractService } from './abstract-service'; +import { DevicesServiceInterface } from '../devices-service'; +import { Device } from '../../models/device'; +import { CreateUserDevice } from './devices/create-user-device'; +import { GetUserDevices } from './devices/get-user-devices'; +import { DeleteUserDevice } from './devices/delete-user-device'; +import { TestUserDevice } from './devices/test-user-device'; +import { UpdateUserDevice } from './devices/update-user-device'; +import { AuthorizationBag } from '../../models/authorization-bag'; +import { DeviceRequest, DeviceResponse, DeviceValuesRequest, GetDevicesResponse } from '../../controllers/devices/dto'; -export class DevicesService - extends AbstractService - implements DevicesServiceInterface { - createUserDevice( - device: DeviceRequest, - authorizationBag: AuthorizationBag - ): Promise<DeviceResponse> { - return this.commandExecutor.execute( - new CreateUserDevice(device, authorizationBag) - ); +export class DevicesService extends AbstractService implements DevicesServiceInterface { + createUserDevice(device: DeviceRequest, authorizationBag: AuthorizationBag): Promise<DeviceResponse> { + return this.commandExecutor.execute(new CreateUserDevice(device, authorizationBag)); } - getUserDevices( - authorizationBag: AuthorizationBag - ): Promise<DeviceResponse[]> { - return this.commandExecutor.execute( - new GetUserDevices(authorizationBag) - ); + getUserDevices(authorizationBag: AuthorizationBag): Promise<GetDevicesResponse> { + return this.commandExecutor.execute(new GetUserDevices(authorizationBag)); } - deleteUserDeviceById( - deviceId: string, - authorizationBag: AuthorizationBag - ): Promise<string> { - return this.commandExecutor.execute( - new DeleteUserDevice(deviceId, authorizationBag) - ); + deleteUserDeviceById(deviceId: string, authorizationBag: AuthorizationBag): Promise<string> { + return this.commandExecutor.execute(new DeleteUserDevice(deviceId, authorizationBag)); } - tryBrowserPushNotification( - deviceId: string, - authorizationBag: AuthorizationBag - ): Promise<string> { - return this.commandExecutor.execute( - new TestUserDevice(deviceId, authorizationBag) - ); + tryBrowserPushNotification(deviceId: string, authorizationBag: AuthorizationBag): Promise<void> { + return this.commandExecutor.execute(new TestUserDevice(deviceId, authorizationBag)); } updateUserDeviceById( deviceId: string, authorizationBag: AuthorizationBag, - name: string, - info: string, + newDeviceValues: DeviceValuesRequest, ): Promise<DeviceResponse> { - return this.commandExecutor.execute( - new UpdateUserDevice(deviceId, authorizationBag, name, info) - ); + return this.commandExecutor.execute(new UpdateUserDevice(deviceId, authorizationBag, newDeviceValues)); } - } diff --git a/src/services/impl/devices/get-user-devices.ts b/src/services/impl/devices/get-user-devices.ts index 280dd50e..57b6553f 100644 --- a/src/services/impl/devices/get-user-devices.ts +++ b/src/services/impl/devices/get-user-devices.ts @@ -1,26 +1,25 @@ -import {Command} from "../command"; -import {EntityManager} from "typeorm"; -import {Device} from "../../../models/device"; -import {AuthorizationBag} from "../../../models/authorization-bag"; -import {DeviceResponse, GetDevicesResponse} from "../../../controllers/devices/dto"; +import { Command } from '../command'; +import { EntityManager } from 'typeorm'; +import { Device } from '../../../models/device'; +import { AuthorizationBag } from '../../../models/authorization-bag'; +import { DeviceResponse, GetDevicesResponse } from '../../../controllers/devices/dto'; export class GetUserDevices implements Command { - constructor(private authorizationBag: AuthorizationBag) { - } + constructor(private authorizationBag: AuthorizationBag) {} - private async fetchUserDevices(transactionManager: EntityManager) { - return await transactionManager - .getRepository(Device) - .createQueryBuilder("device") - .leftJoin("device.user", "user") - .where("user.id = :userid", { - userid: this.authorizationBag.userId - }) - .getMany(); - } + private async fetchUserDevices(transactionManager: EntityManager) { + return await transactionManager + .getRepository(Device) + .createQueryBuilder('device') + .leftJoin('device.user', 'user') + .where('user.id = :userid', { + userid: this.authorizationBag.userId, + }) + .getMany(); + } - async execute(transactionManager: EntityManager) { - const devices = await this.fetchUserDevices(transactionManager); - return new GetDevicesResponse(devices.map(device => new DeviceResponse(device))); - } + async execute(transactionManager: EntityManager): Promise<GetDevicesResponse> { + const devices = await this.fetchUserDevices(transactionManager); + return new GetDevicesResponse(devices.map(device => new DeviceResponse(device))); + } } diff --git a/src/services/impl/devices/test-user-device.ts b/src/services/impl/devices/test-user-device.ts index 73b9f379..9db72b51 100644 --- a/src/services/impl/devices/test-user-device.ts +++ b/src/services/impl/devices/test-user-device.ts @@ -10,7 +10,7 @@ import { BadRequestError, ForbiddenError } from 'routing-controllers'; export class TestUserDevice implements Command { constructor(private deviceId: string, private authorizationBag: AuthorizationBag) {} - async execute(transactionManager: EntityManager) { + async execute(transactionManager: EntityManager): Promise<void> { let selectedDevice = await transactionManager .getRepository(Device) .createQueryBuilder('device') diff --git a/src/services/impl/devices/update-user-device.ts b/src/services/impl/devices/update-user-device.ts index ec2b2adb..631716b2 100644 --- a/src/services/impl/devices/update-user-device.ts +++ b/src/services/impl/devices/update-user-device.ts @@ -1,34 +1,34 @@ -import {Command} from "../command"; -import {EntityManager} from "typeorm"; -import {Device} from "../../../models/device"; -import {AuthorizationBag} from "../../../models/authorization-bag"; -import {ForbiddenError} from "routing-controllers"; -import {DeviceResponse} from "../../../controllers/devices/dto"; +import { Command } from '../command'; +import { EntityManager } from 'typeorm'; +import { Device } from '../../../models/device'; +import { AuthorizationBag } from '../../../models/authorization-bag'; +import { ForbiddenError } from 'routing-controllers'; +import { DeviceResponse, DeviceValuesRequest } from '../../../controllers/devices/dto'; export class UpdateUserDevice implements Command { - constructor(private deviceId: string, private authorizationBag: AuthorizationBag, private name: string, private info: string) { - } + constructor( + private deviceId: string, + private authorizationBag: AuthorizationBag, + private newDeviceValues: DeviceValuesRequest, + ) {} - async execute(transactionManager: EntityManager) { - let result = await transactionManager.update( - Device, - { - id: this.deviceId, - user: this.authorizationBag.userId, - }, - { - name: this.name, - info: this.info, - }); + async execute(transactionManager: EntityManager): Promise<DeviceResponse> { + let result = await transactionManager.update( + Device, + { + id: this.deviceId, + user: this.authorizationBag.userId, + }, + { + name: this.newDeviceValues.name, + info: this.newDeviceValues.info, + }, + ); - if (result.affected !== 1) - throw new ForbiddenError("The device does not exist or you are not the owner"); + if (result.affected !== 1) throw new ForbiddenError('The device does not exist or you are not the owner'); - const response = await transactionManager.findOne( - Device, - {id: this.deviceId} - ); + const response = await transactionManager.findOne(Device, { id: this.deviceId }); - return new DeviceResponse(response) - } + return new DeviceResponse(response); + } } diff --git a/src/services/impl/notifications-service-impl.ts b/src/services/impl/notifications-service-impl.ts index 4cfcd08f..f172f803 100644 --- a/src/services/impl/notifications-service-impl.ts +++ b/src/services/impl/notifications-service-impl.ts @@ -1,46 +1,36 @@ -import { NotificationsService } from "../notifications-service"; -import { Notification } from "../../models/notification"; -import { SendNotification } from "./notifications/send-notification"; -import { FindAllNotifications } from "./notifications/find-all-notifications"; -import { GetById } from "./notifications/get-by-id"; -import { AbstractService } from "./abstract-service"; -import { UserNotification } from "../../models/user-notification"; -import { UpdateUserNotification } from "./notifications/update-user-notification"; -import { AuthorizationBag } from "../../models/authorization-bag"; -import {RetryNotification} from "./notifications/retry-notification"; +import { NotificationsService } from '../notifications-service'; +import { Notification } from '../../models/notification'; +import { SendNotification } from './notifications/send-notification'; +import { FindAllNotifications } from './notifications/find-all-notifications'; +import { GetById } from './notifications/get-by-id'; +import { AbstractService } from './abstract-service'; +import { UserNotification } from '../../models/user-notification'; +import { UpdateUserNotification } from './notifications/update-user-notification'; +import { AuthorizationBag } from '../../models/authorization-bag'; +import { RetryNotification } from './notifications/retry-notification'; +import { GetNotificationResponse, SendNotificationRequest } from '../../controllers/notifications/dto'; -export class NotificationsServiceImpl extends AbstractService - implements NotificationsService { - sendNotification(notification: Notification, authorizationBag: AuthorizationBag): Promise<Notification> { - return this.commandExecutor.execute( - new SendNotification(notification, authorizationBag) - ); +export class NotificationsServiceImpl extends AbstractService implements NotificationsService { + sendNotification( + notification: SendNotificationRequest, + authorizationBag: AuthorizationBag, + ): Promise<GetNotificationResponse> { + return this.commandExecutor.execute(new SendNotification(notification, authorizationBag)); } retryNotification(notificationId: string, authorizationBag: AuthorizationBag): Promise<void> { - return this.commandExecutor.execute( - new RetryNotification(notificationId, authorizationBag) - ); + return this.commandExecutor.execute(new RetryNotification(notificationId, authorizationBag)); } findAllNotifications(channelId: string, query: any, authorizationBag: AuthorizationBag): Promise<Notification[]> { - return this.commandExecutor.execute( - new FindAllNotifications(channelId, query, authorizationBag) - ); + return this.commandExecutor.execute(new FindAllNotifications(channelId, query, authorizationBag)); } - getById(notificationId: string, query: any, authorizationBag: AuthorizationBag): Promise<Notification> { - return this.commandExecutor.execute( - new GetById(notificationId, query, authorizationBag) - ); + getById(notificationId: string, authorizationBag: AuthorizationBag): Promise<GetNotificationResponse> { + return this.commandExecutor.execute(new GetById(notificationId, authorizationBag)); } - updateUserNotification( - notification: UserNotification, - authorizationBag: AuthorizationBag - ): Promise<any> { - return this.commandExecutor.execute( - new UpdateUserNotification(notification, authorizationBag) - ); + updateUserNotification(notification: UserNotification, authorizationBag: AuthorizationBag): Promise<any> { + return this.commandExecutor.execute(new UpdateUserNotification(notification, authorizationBag)); } } diff --git a/src/services/impl/notifications/get-by-id.ts b/src/services/impl/notifications/get-by-id.ts index f8a3b516..709160d5 100644 --- a/src/services/impl/notifications/get-by-id.ts +++ b/src/services/impl/notifications/get-by-id.ts @@ -3,11 +3,12 @@ import { EntityManager } from 'typeorm'; import { Notification } from '../../../models/notification'; import { ForbiddenError, NotFoundError } from 'routing-controllers'; import { AuthorizationBag } from '../../../models/authorization-bag'; +import { GetNotificationResponse } from '../../../controllers/notifications/dto'; export class GetById implements Command { - constructor(private notificationId: string, private query: any, private authorizationBag: AuthorizationBag) {} + constructor(private notificationId: string, private authorizationBag: AuthorizationBag) {} - async execute(transactionManager: EntityManager) { + async execute(transactionManager: EntityManager): Promise<GetNotificationResponse> { const notification = await transactionManager.findOne(Notification, { relations: [ 'target', @@ -40,6 +41,6 @@ export class GetById implements Command { throw new ForbiddenError('Access to notification not authorized.'); } - return notification; + return new GetNotificationResponse(notification); } } diff --git a/src/services/impl/notifications/send-notification.ts b/src/services/impl/notifications/send-notification.ts index 017a2f35..8cf25e6e 100644 --- a/src/services/impl/notifications/send-notification.ts +++ b/src/services/impl/notifications/send-notification.ts @@ -3,7 +3,8 @@ import * as stompit from 'stompit'; import { EntityManager } from 'typeorm'; import { BadRequestError, ForbiddenError, NotFoundError } from 'routing-controllers'; import { Command } from '../command'; -import { Notification, PriorityLevel, SendDateFormat, Times } from '../../../models/notification'; +import { Notification } from '../../../models/notification'; +import { SendDateFormat, Times, Source, PriorityLevel } from '../../../models/notification-enums'; import { Channel } from '../../../models/channel'; import { User } from '../../../models/user'; import { Group } from '../../../models/group'; @@ -16,15 +17,15 @@ import { UsersServiceInterface } from '../../users-service'; import { GroupsServiceInterface } from '../../groups-service'; import * as memoize from 'memoizee'; import { AuditNotifications } from '../../../log/auditing'; -import { ChannelFlags } from '../../../models/channel-enums'; +import { GetNotificationResponse, SendNotificationRequest } from '../../../controllers/notifications/dto'; export class SendNotification implements Command { private usersService: UsersServiceInterface = ServiceFactory.getUserService(); private groupsService: GroupsServiceInterface = ServiceFactory.getGroupService(); - constructor(private notification, private authorizationBag: AuthorizationBag) {} + constructor(private notification: SendNotificationRequest, private authorizationBag: AuthorizationBag) {} - async execute(transactionManager: EntityManager) { + async execute(transactionManager: EntityManager): Promise<GetNotificationResponse> { const targetChannel = await transactionManager.findOne(Channel, { relations: ['members', 'groups', 'owner', 'adminGroup', 'category'], where: { id: this.notification.target }, @@ -36,7 +37,9 @@ export class SendNotification implements Command { if ( !(await targetChannel.canSendByForm( this.authorizationBag, - this.notification.targetUsers || this.notification.targetGroups || this.notification.targetData, + this.notification.targetUsers !== undefined || + this.notification.targetGroups !== undefined || + this.notification.targetData !== undefined, )) ) throw new ForbiddenError('Sending to Channel not Authorized !'); @@ -66,14 +69,14 @@ export class SendNotification implements Command { if (!id) return; const identifier = id.toLowerCase(); // no @ mean it's a group name (or a mistake that we'll ignore) - if (!identifier.includes('@')) this.notification.targetGroups.push({ groupIdentifier: identifier }); + if (!identifier.includes('@')) this.notification.targetGroups.push(new Group({ groupIdentifier: identifier })); // @domain is not @cern.ch then it's an external user - else if (!identifier.includes('@cern.ch')) this.notification.targetUsers.push({ email: identifier }); + else if (!identifier.includes('@cern.ch')) this.notification.targetUsers.push(new User({ email: identifier })); // email@cern.ch contains a - so it's a group else if (identifier.includes('-')) - this.notification.targetGroups.push({ groupIdentifier: identifier.replace('@cern.ch', '') }); + this.notification.targetGroups.push(new Group({ groupIdentifier: identifier.replace('@cern.ch', '') })); // And the rest is handled as user email - else this.notification.targetUsers.push({ email: identifier }); + else this.notification.targetUsers.push(new User({ email: identifier })); }); } @@ -97,18 +100,17 @@ export class SendNotification implements Command { ); } - const notificationInDB = await transactionManager.findOne(Notification, { - id: this.notification.id, - }); - if (notificationInDB) throw new BadRequestError('The notification already exists in the database'); + const source = + this.authorizationBag?.isAnonymous || this.authorizationBag?.isApiKey ? Source.api : this.notification.source; const newNotification = await transactionManager.save( new Notification({ ...this.notification, + source, target: targetChannel, - sender: this.notification.sender || this.authorizationBag.email, - targetUsers: targetUsers, - targetGroups: targetGroups, + sender: this.authorizationBag?.email || this.notification.sender, + targetUsers, + targetGroups, }), ); @@ -123,15 +125,17 @@ export class SendNotification implements Command { await AuditNotifications.setValue(newNotification.id, { event: 'Sent', - user: this.authorizationBag.email, + user: this.authorizationBag?.email || this.notification.sender, from: newNotification.source, }); // Get back object from DB, without full info about target Channel - return await transactionManager.findOne(Notification, { - relations: ['targetUsers', 'targetGroups'], - where: { id: newNotification.id }, - }); + return new GetNotificationResponse( + await transactionManager.findOne(Notification, { + relations: ['targetUsers', 'targetGroups'], + where: { id: newNotification.id }, + }), + ); } async sendToQueue(notification: Notification): Promise<void> { @@ -293,7 +297,7 @@ export class SendNotification implements Command { // Update lastActivityDate from the Channel with the current Date static async _updateLastActivityNoCache(channel_id: string, transactionManager: EntityManager, channel: Channel) { - let emptyChannelObject = channel.getEmptyChannelObjectForQuickUpdate(); + const emptyChannelObject = channel.getEmptyChannelObjectForQuickUpdate(); emptyChannelObject.lastActivityDate = new Date(); return await transactionManager.save(emptyChannelObject); } diff --git a/src/services/impl/notifications/update-user-notification.ts b/src/services/impl/notifications/update-user-notification.ts index 22569e29..6e037de7 100644 --- a/src/services/impl/notifications/update-user-notification.ts +++ b/src/services/impl/notifications/update-user-notification.ts @@ -1,42 +1,40 @@ -import { EntityManager } from "typeorm"; -import { Command } from "../command"; -import { Notification } from "../../../models/notification"; -import { UserNotification } from "../../../models/user-notification"; -import { AuthorizationBag } from "../../../models/authorization-bag"; +import { EntityManager } from 'typeorm'; +import { Command } from '../command'; +import { Notification } from '../../../models/notification'; +import { UserNotification } from '../../../models/user-notification'; +import { AuthorizationBag } from '../../../models/authorization-bag'; +//TODO: Legacy cleanup? export class UpdateUserNotification implements Command { - constructor(private notification, private authorizationBag: AuthorizationBag) { - delete this.notification.id; - } + constructor(private notification, private authorizationBag: AuthorizationBag) { + delete this.notification.id; + } - async execute(transactionManager: EntityManager): Promise<any> { - if (await this.isNotificationOfUser(transactionManager)) { - let userNotification = await transactionManager.findOne( - UserNotification, - { - user: { id: this.authorizationBag.userId }, - notification: this.notification.notification - } - ); + async execute(transactionManager: EntityManager): Promise<any> { + if (await this.isNotificationOfUser(transactionManager)) { + let userNotification = await transactionManager.findOne(UserNotification, { + user: { id: this.authorizationBag.userId }, + notification: this.notification.notification, + }); - userNotification = await transactionManager.save(UserNotification, { ...userNotification, ...this.notification }); - delete userNotification.id; - delete userNotification.notification; - delete userNotification.user; + userNotification = await transactionManager.save(UserNotification, { ...userNotification, ...this.notification }); + delete userNotification.id; + delete userNotification.notification; + delete userNotification.user; - return { - ...await transactionManager.findOne( - Notification, - { id: this.notification.notification }), ...userNotification - }; - } + return { + ...(await transactionManager.findOne(Notification, { id: this.notification.notification })), + ...userNotification, + }; } + } - async isNotificationOfUser(transactionManager: EntityManager): Promise<boolean> { - const notification = await transactionManager.findOne( - Notification, - { id: this.notification.notification }, { relations: ['users', 'users.user'] } - ); - return notification.users.filter(u => u.user.id === this.authorizationBag.userId).length === 1; - } -} \ No newline at end of file + async isNotificationOfUser(transactionManager: EntityManager): Promise<boolean> { + const notification = await transactionManager.findOne( + Notification, + { id: this.notification.notification }, + { relations: ['users', 'users.user'] }, + ); + return notification.users.filter(u => u.user.id === this.authorizationBag.userId).length === 1; + } +} diff --git a/src/services/notifications-service.ts b/src/services/notifications-service.ts index 3e218063..46cce9ed 100644 --- a/src/services/notifications-service.ts +++ b/src/services/notifications-service.ts @@ -1,20 +1,19 @@ -import { Notification } from "../models/notification"; -import { UserNotification } from "../models/user-notification"; -import { AuthorizationBag } from "../models/authorization-bag"; +import { Notification } from '../models/notification'; +import { UserNotification } from '../models/user-notification'; +import { AuthorizationBag } from '../models/authorization-bag'; +import { GetNotificationResponse, SendNotificationRequest } from '../controllers/notifications/dto'; export interface NotificationsService { - - sendNotification(notification: Notification, authorizationBag: AuthorizationBag): Promise<Notification>; + sendNotification( + notification: SendNotificationRequest, + authorizationBag: AuthorizationBag, + ): Promise<GetNotificationResponse>; retryNotification(notificationId: string, authorizationBag: AuthorizationBag): Promise<void>; findAllNotifications(channelId: string, query: any, authorizationBag: AuthorizationBag): Promise<Notification[]>; - getById(notificationId: string, query: any, authorizationBag: AuthorizationBag): Promise<Notification>; - - updateUserNotification( - notification: UserNotification, - authorizationBag: AuthorizationBag, - ): Promise<any>; + getById(notificationId: string, authorizationBag: AuthorizationBag): Promise<GetNotificationResponse>; + updateUserNotification(notification: UserNotification, authorizationBag: AuthorizationBag): Promise<any>; } diff --git a/src/utils/status-codes.ts b/src/utils/status-codes.ts new file mode 100644 index 00000000..f5648507 --- /dev/null +++ b/src/utils/status-codes.ts @@ -0,0 +1,116 @@ +export const enum StatusCodes { + Accepted = '202', + BadGateway = '502', + BadRequest = '400', + Conflict = '409', + Continue = '100', + Created = '201', + ExpectationFailed = '417', + FailedDependency = '424', + Forbidden = '403', + GatewayTimeout = '504', + Gone = '410', + HTTPVersionNotSupported = '505', + ImATeapot = '418', + InsufficientSpaceOnResource = '419', + InsufficientStorage = '507', + InternalServerError = '500', + LengthRequired = '411', + Locked = '423', + MethodFailure = '420', + MethodNotAllowed = '405', + MovedPermanently = '301', + MovedTemporarily = '302', + MultiStatus = '207', + MultipleChoices = '300', + NetworkAuthenticationRequired = '511', + NoContent = '204', + NonAuthoritativeInformation = '203', + NotAcceptable = '406', + NotFound = '404', + NotImplemented = '501', + NotModified = '304', + OK = '200', + PartialContent = '206', + PaymentRequired = '402', + PermanentRedirect = '308', + PreconditionFailed = '412', + PreconditionRequired = '428', + Processing = '102', + ProxyAuthenticationRequired = '407', + RequestHeaderFieldsTooLarge = '431', + RequestTimeout = '408', + RequestEntityTooLarge = '413', + RequestURITooLong = '414', + RequestedRangeNotSatisfiable = '416', + ResetContent = '205', + SeeOther = '303', + ServiceUnavailable = '503', + SwitchingProtocols = '101', + TemporaryRedirect = '307', + TooManyRequests = '429', + Unauthorized = '401', + UnavailableForLegalReasons = '451', + UnprocessableEntity = '422', + UnsupportedMediaType = '415', + UseProxy = '305', + MisdirectedRequest = '421', +} + +export const StatusCodeDescriptions: Record<string, object> = { + [StatusCodes.Accepted]: { description: 'Accepted' }, + [StatusCodes.BadGateway]: { description: 'Bad Gateway' }, + [StatusCodes.BadRequest]: { description: 'Bad Request' }, + [StatusCodes.Conflict]: { description: 'Conflict' }, + [StatusCodes.Continue]: { description: 'Continue' }, + [StatusCodes.Created]: { description: 'Created' }, + [StatusCodes.ExpectationFailed]: { description: 'Expectation Failed' }, + [StatusCodes.FailedDependency]: { description: 'Failed Dependency' }, + [StatusCodes.Forbidden]: { description: 'Forbidden' }, + [StatusCodes.GatewayTimeout]: { description: 'Gateway Timeout' }, + [StatusCodes.Gone]: { description: 'Gone' }, + [StatusCodes.HTTPVersionNotSupported]: { description: 'HTTP Version Not Supported' }, + [StatusCodes.ImATeapot]: { description: "I'm a teapot" }, + [StatusCodes.InsufficientSpaceOnResource]: { description: 'Insufficient Space on Resource' }, + [StatusCodes.InsufficientStorage]: { description: 'Insufficient Storage' }, + [StatusCodes.InternalServerError]: { description: 'Internal Server Error' }, + [StatusCodes.LengthRequired]: { description: 'Length Required' }, + [StatusCodes.Locked]: { description: 'Locked' }, + [StatusCodes.MethodFailure]: { description: 'Method Failure' }, + [StatusCodes.MethodNotAllowed]: { description: 'Method Not Allowed' }, + [StatusCodes.MovedPermanently]: { description: 'Moved Permanently' }, + [StatusCodes.MovedTemporarily]: { description: 'Moved Temporarily' }, + [StatusCodes.MultiStatus]: { description: 'Multi - Status' }, + [StatusCodes.MultipleChoices]: { description: 'Multiple Choices' }, + [StatusCodes.NetworkAuthenticationRequired]: { description: 'Network Authentication Required' }, + [StatusCodes.NoContent]: { description: 'No Content' }, + [StatusCodes.NonAuthoritativeInformation]: { description: 'Non Authoritative Information' }, + [StatusCodes.NotAcceptable]: { description: 'Not Acceptable' }, + [StatusCodes.NotFound]: { description: 'Not Found' }, + [StatusCodes.NotImplemented]: { description: 'Not Implemented' }, + [StatusCodes.NotModified]: { description: 'Not Modified' }, + [StatusCodes.PartialContent]: { description: 'Partial Content' }, + [StatusCodes.PaymentRequired]: { description: 'Payment Required' }, + [StatusCodes.PermanentRedirect]: { description: 'Permanent Redirect' }, + [StatusCodes.PreconditionFailed]: { description: 'Precondition Failed' }, + [StatusCodes.PreconditionRequired]: { description: 'Precondition Required' }, + [StatusCodes.Processing]: { description: 'Processing' }, + [StatusCodes.ProxyAuthenticationRequired]: { description: 'Proxy Authentication Required' }, + [StatusCodes.RequestHeaderFieldsTooLarge]: { description: 'Request Header Fields Too Large' }, + [StatusCodes.RequestTimeout]: { description: 'Request Timeout' }, + [StatusCodes.RequestEntityTooLarge]: { description: 'Request Entity Too Large' }, + [StatusCodes.RequestURITooLong]: { description: 'Request - URI Too Long' }, + [StatusCodes.RequestedRangeNotSatisfiable]: { description: 'Requested Range Not Satisfiable' }, + [StatusCodes.ResetContent]: { description: 'Reset Content' }, + [StatusCodes.SeeOther]: { description: 'See Other' }, + [StatusCodes.ServiceUnavailable]: { description: 'Service Unavailable' }, + [StatusCodes.SwitchingProtocols]: { description: 'Switching Protocols' }, + [StatusCodes.TemporaryRedirect]: { description: 'Temporary Redirect' }, + [StatusCodes.TooManyRequests]: { description: 'Too Many Requests' }, + [StatusCodes.Unauthorized]: { description: 'Unauthorized' }, + [StatusCodes.UnavailableForLegalReasons]: { description: 'Unavailable For Legal Reasons' }, + [StatusCodes.UnprocessableEntity]: { description: 'Unprocessable Entity' }, + [StatusCodes.UnsupportedMediaType]: { description: 'Unsupported Media Type' }, + [StatusCodes.UseProxy]: { description: 'Use Proxy' }, + [StatusCodes.MisdirectedRequest]: { description: 'Misdirected Request' }, +}; -- GitLab