From 35bfc44ee2866f3ef4945d33d7faf7a4ff5f038e Mon Sep 17 00:00:00 2001 From: John Rassa Date: Mon, 9 Dec 2024 12:53:21 -0500 Subject: [PATCH] feat: Use TypeBox for Fastify schema definitions Switched to using TypeBox due to TS errors when using JsonSchema. TypeBox can also be used for defining Mongoose types to reduce duplication. --- package-lock.json | 72 ++--- package.json | 3 +- .../access-checker.controller.ts | 83 +++-- .../access-checker/cache/cache-entry.model.ts | 20 +- src/app/core/audit/audit.controller.ts | 68 ++-- src/app/core/audit/audit.model.ts | 36 ++- src/app/core/core.schemas.ts | 35 -- src/app/core/core.types.ts | 50 +++ .../core/export/export-config.controller.ts | 57 +--- src/app/core/export/export-config.service.ts | 5 +- src/app/core/export/export-config.types.ts | 19 ++ .../feedback-admin.controller.spec.ts | 126 ++++++++ .../feedback/feedback-admin.controller.ts | 177 ++++++++++ .../core/feedback/feedback.controller.spec.ts | 109 ++----- src/app/core/feedback/feedback.controller.ts | 216 +------------ src/app/core/feedback/feedback.model.ts | 35 +- src/app/core/feedback/feedback.types.ts | 14 + .../core/messages/dismissed-message.model.ts | 18 +- .../core/messages/message-admin.controller.ts | 107 +++++++ src/app/core/messages/message.controller.ts | 148 +-------- src/app/core/messages/message.hooks.ts | 13 + src/app/core/messages/message.model.ts | 30 +- src/app/core/messages/message.types.ts | 13 + .../core/messages/messages.service.spec.ts | 2 +- src/app/core/messages/messages.service.ts | 13 +- src/app/core/metrics/metrics.controller.ts | 4 +- .../notifications/notification.controller.ts | 14 +- .../core/notifications/notification.model.ts | 18 +- src/app/core/teams/team-members.controller.ts | 199 ++++++++++++ src/app/core/teams/team-role.model.ts | 26 +- src/app/core/teams/team.hooks.ts | 27 ++ src/app/core/teams/team.model.ts | 39 ++- src/app/core/teams/team.types.ts | 50 +++ src/app/core/teams/teams.controller.ts | 302 ++---------------- src/app/core/teams/teams.service.ts | 2 +- src/app/core/user/auth/auth.controller.ts | 60 +--- src/app/core/user/auth/auth.types.ts | 15 + src/app/core/user/eua/eua.controller.ts | 26 +- .../user/{admin => }/user-admin.controller.ts | 93 +++--- src/app/core/user/user.controller.ts | 46 ++- src/app/core/user/user.model.spec.ts | 36 +-- src/app/core/user/user.model.ts | 108 +++---- src/app/core/user/user.types.ts | 28 ++ src/lib/fastify.ts | 3 +- 44 files changed, 1337 insertions(+), 1228 deletions(-) delete mode 100644 src/app/core/core.schemas.ts create mode 100644 src/app/core/core.types.ts create mode 100644 src/app/core/export/export-config.types.ts create mode 100644 src/app/core/feedback/feedback-admin.controller.spec.ts create mode 100644 src/app/core/feedback/feedback-admin.controller.ts create mode 100644 src/app/core/feedback/feedback.types.ts create mode 100644 src/app/core/messages/message-admin.controller.ts create mode 100644 src/app/core/messages/message.hooks.ts create mode 100644 src/app/core/messages/message.types.ts create mode 100644 src/app/core/teams/team-members.controller.ts create mode 100644 src/app/core/teams/team.hooks.ts create mode 100644 src/app/core/teams/team.types.ts create mode 100644 src/app/core/user/auth/auth.types.ts rename src/app/core/user/{admin => }/user-admin.controller.ts (77%) create mode 100644 src/app/core/user/user.types.ts diff --git a/package-lock.json b/package-lock.json index e53fca67..1c806b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,9 @@ "@fastify/session": "^11.0.1", "@fastify/swagger": "^9.2.0", "@fastify/swagger-ui": "^5.1.0", - "@fastify/type-provider-json-schema-to-ts": "^4.0.1", + "@fastify/type-provider-typebox": "^5.0.1", + "@sinclair/typebox": "^0.33.22", "agenda": "^4.3.0", - "async": "^3.2.5", "config": "^3.3.11", "connect-mongo": "^5.1.0", "cors": "^2.8.5", @@ -1791,13 +1791,13 @@ "node": ">= 14" } }, - "node_modules/@fastify/type-provider-json-schema-to-ts": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@fastify/type-provider-json-schema-to-ts/-/type-provider-json-schema-to-ts-4.0.1.tgz", - "integrity": "sha512-+QS1iiRZMAqCcWX7Ck8zAVXb1WbjpffC2gOYxDGvF1wtLviblz7HtnjAX04bW6ZsgreZP0RktRPlEAzmEafEQw==", + "node_modules/@fastify/type-provider-typebox": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-5.0.1.tgz", + "integrity": "sha512-zepdCWmgvpcLS06DN5vznMJLUP/5gLt/X3lVXZvddXmHSImQxm2Em+dV64b6x9P4G4V9DKuS4EL08ASjpCboNA==", "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.0" + "peerDependencies": { + "@sinclair/typebox": ">=0.26 <=0.33" } }, "node_modules/@humanwhocodes/config-array": { @@ -2148,6 +2148,12 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@sinclair/typebox": { + "version": "0.33.22", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.22.tgz", + "integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -8337,19 +8343,6 @@ "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" } }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -12202,12 +12195,6 @@ "node": ">= 14.0.0" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -14327,13 +14314,11 @@ } } }, - "@fastify/type-provider-json-schema-to-ts": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@fastify/type-provider-json-schema-to-ts/-/type-provider-json-schema-to-ts-4.0.1.tgz", - "integrity": "sha512-+QS1iiRZMAqCcWX7Ck8zAVXb1WbjpffC2gOYxDGvF1wtLviblz7HtnjAX04bW6ZsgreZP0RktRPlEAzmEafEQw==", - "requires": { - "json-schema-to-ts": "^3.1.0" - } + "@fastify/type-provider-typebox": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-5.0.1.tgz", + "integrity": "sha512-zepdCWmgvpcLS06DN5vznMJLUP/5gLt/X3lVXZvddXmHSImQxm2Em+dV64b6x9P4G4V9DKuS4EL08ASjpCboNA==", + "requires": {} }, "@humanwhocodes/config-array": { "version": "0.11.14", @@ -14574,6 +14559,11 @@ "tslib": "^2.6.0" } }, + "@sinclair/typebox": { + "version": "0.33.22", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.22.tgz", + "integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==" + }, "@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -18876,15 +18866,6 @@ "uri-js": "^4.2.2" } }, - "json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "requires": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - } - }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -21504,11 +21485,6 @@ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==" }, - "ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" - }, "ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", diff --git a/package.json b/package.json index 1143708c..436cb9de 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "@fastify/session": "^11.0.1", "@fastify/swagger": "^9.2.0", "@fastify/swagger-ui": "^5.1.0", - "@fastify/type-provider-json-schema-to-ts": "^4.0.1", + "@fastify/type-provider-typebox": "^5.0.1", + "@sinclair/typebox": "^0.33.22", "agenda": "^4.3.0", "config": "^3.3.11", "connect-mongo": "^5.1.0", diff --git a/src/app/core/access-checker/access-checker.controller.ts b/src/app/core/access-checker/access-checker.controller.ts index 4dbe2317..eb36f839 100644 --- a/src/app/core/access-checker/access-checker.controller.ts +++ b/src/app/core/access-checker/access-checker.controller.ts @@ -1,82 +1,75 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; import accessCheckerService from './access-checker.service'; import cacheEntryService from './cache/cache-entry.service'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; +import { PagingQueryStringType, SearchBodyType } from '../core.types'; import { requireAdminAccess, requireLogin } from '../user/auth/auth.hooks'; +const KeyParamsType = Type.Object({ + key: Type.String() +}); + export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); + fastify.route({ method: 'POST', - url: '/access-checker/entry/:key', + url: '/access-checker/entries/match', schema: { - description: 'Trigger cache entry refresh', + description: 'Search cache entries', tags: ['Access Checker'], - params: { - type: 'object', - properties: { - key: { type: 'string' } - }, - required: ['key'] - } + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAdminAccess, handler: async function (req, reply) { - await accessCheckerService.refreshEntry(req.params.key); - return reply.send(); + const results = await cacheEntryService.search( + req.query, + req.body.s, + req.body.q + ); + + // Create the return copy of the messages + const mappedResults = { + pageNumber: results.pageNumber, + pageSize: results.pageSize, + totalPages: results.totalPages, + totalSize: results.totalSize, + elements: results.elements.map((element) => element.fullCopy()) + }; + + return reply.send(mappedResults); } }); fastify.route({ - method: 'DELETE', + method: 'POST', url: '/access-checker/entry/:key', schema: { - description: 'Delete cache entry', + description: 'Trigger cache entry refresh', tags: ['Access Checker'], - params: { - type: 'object', - properties: { - key: { type: 'string' } - }, - required: ['key'] - } + params: KeyParamsType }, preValidation: requireAdminAccess, handler: async function (req, reply) { - await cacheEntryService.delete(req.params.key); + await accessCheckerService.refreshEntry(req.params.key); return reply.send(); } }); fastify.route({ - method: 'POST', - url: '/access-checker/entries/match', + method: 'DELETE', + url: '/access-checker/entry/:key', schema: { - description: 'Search cache entries', + description: 'Delete cache entry', tags: ['Access Checker'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + params: KeyParamsType }, preValidation: requireAdminAccess, handler: async function (req, reply) { - const results = await cacheEntryService.search( - req.query, - req.body.s, - req.body.q - ); - - // Create the return copy of the messages - const mappedResults = { - pageNumber: results.pageNumber, - pageSize: results.pageSize, - totalPages: results.totalPages, - totalSize: results.totalSize, - elements: results.elements.map((element) => element.fullCopy()) - }; - - return reply.send(mappedResults); + await cacheEntryService.delete(req.params.key); + return reply.send(); } }); diff --git a/src/app/core/access-checker/cache/cache-entry.model.ts b/src/app/core/access-checker/cache/cache-entry.model.ts index 4e9a3c2c..638cda0b 100644 --- a/src/app/core/access-checker/cache/cache-entry.model.ts +++ b/src/app/core/access-checker/cache/cache-entry.model.ts @@ -1,4 +1,5 @@ -import { Schema, model, HydratedDocument, Model, Types } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { Schema, model, HydratedDocument, Model } from 'mongoose'; import { ContainsSearchable, @@ -9,14 +10,17 @@ import { paginatePlugin, Paginateable } from '../../../common/mongoose/paginate.plugin'; +import { DateTimeType, ObjectIdType } from '../../core.types'; + +export const CacheEntryType = Type.Object({ + _id: ObjectIdType, + key: Type.String(), + ts: DateTimeType, + value: Type.Record(Type.String(), Type.Unknown()), + valueString: Type.String() +}); -export interface ICacheEntry { - _id: Types.ObjectId; - key: string; - ts: Date; - value: Record; - valueString: string; -} +export type ICacheEntry = Static; export interface ICacheEntryMethods { fullCopy(): Record; diff --git a/src/app/core/audit/audit.controller.ts b/src/app/core/audit/audit.controller.ts index f4306a3f..8fc4f484 100644 --- a/src/app/core/audit/audit.controller.ts +++ b/src/app/core/audit/audit.controller.ts @@ -1,39 +1,22 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; import _ from 'lodash'; import { FilterQuery } from 'mongoose'; -import { Audit, AuditDocument } from './audit.model'; +import { Audit, AuditDocument, AuditType } from './audit.model'; import { config, utilService as util } from '../../../dependencies'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; +import { + PagingQueryStringType, + PagingResultsType, + SearchBodyType +} from '../core.types'; import { Callbacks } from '../export/callbacks'; import * as exportConfigController from '../export/export-config.controller'; import { loadExportConfigById } from '../export/export-config.controller'; import { requireAuditorAccess } from '../user/auth/auth.hooks'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); - fastify.route({ - method: 'GET', - url: '/audit/distinctValues', - schema: { - description: - 'Retrieves the distinct values for a field in the Audit collection', - tags: ['Audit'], - querystring: { - type: 'object', - properties: { - field: { type: 'string' } - }, - required: ['field'] - } - }, - preValidation: requireAuditorAccess, - handler: async function (req, reply) { - const results = await Audit.distinct(req.query.field, {}); - return reply.send(results); - } - }); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', @@ -41,8 +24,12 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Returns audit records matching search criteria', tags: ['Audit'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + hide: true, + body: SearchBodyType, + querystring: PagingQueryStringType, + response: { + 200: PagingResultsType(AuditType) + } }, preValidation: requireAuditorAccess, handler: async function (req, reply) { @@ -79,12 +66,35 @@ export default function (_fastify: FastifyInstance) { } }); + fastify.route({ + method: 'GET', + url: '/audit/distinctValues', + schema: { + description: + 'Retrieves the distinct values for a field in the Audit collection', + tags: ['Audit'], + hide: true, + querystring: Type.Object({ + field: Type.String() + }) + }, + preValidation: requireAuditorAccess, + handler: async function (req, reply) { + const results = await Audit.distinct(req.query.field, {}); + return reply.send(results); + } + }); + fastify.route({ method: 'GET', url: '/audit/csv/:id', schema: { description: 'Export audit records as CSV file', - tags: ['Audit'] + tags: ['Audit'], + hide: true, + params: Type.Object({ + id: Type.String() + }) }, preValidation: requireAuditorAccess, preHandler: loadExportConfigById, @@ -121,6 +131,8 @@ export default function (_fastify: FastifyInstance) { .cursor(); exportConfigController.exportCSV(req, reply, fileName, columns, cursor); + + return reply; } }); } diff --git a/src/app/core/audit/audit.model.ts b/src/app/core/audit/audit.model.ts index 22d5d68a..4139437c 100644 --- a/src/app/core/audit/audit.model.ts +++ b/src/app/core/audit/audit.model.ts @@ -1,3 +1,4 @@ +import { Static, Type } from '@fastify/type-provider-typebox'; import config from 'config'; import { HydratedDocument, Model, model, Schema } from 'mongoose'; @@ -10,22 +11,27 @@ import { Paginateable, paginatePlugin } from '../../common/mongoose/paginate.plugin'; +import { DateTimeType } from '../core.types'; + +export const AuditType = Type.Object({ + created: DateTimeType, + message: Type.String(), + audit: Type.Object({ + auditType: Type.String(), + action: Type.String(), + actor: Type.Record(Type.String(), Type.Unknown()), + object: Type.Optional( + Type.Union([Type.String(), Type.Record(Type.String(), Type.Unknown())]) + ), + userSpec: Type.Object({ + browser: Type.String(), + os: Type.String() + }), + masqueradingUser: Type.Optional(Type.String()) + }) +}); -interface IAudit { - created: Date; - message: string; - audit: { - auditType: string; - action: string; - actor: Record; - object: string | Record; - userSpec: { - browser: string; - os: string; - }; - masqueradingUser?: string; - }; -} +type IAudit = Static; export type AuditDocument = HydratedDocument< IAudit, diff --git a/src/app/core/core.schemas.ts b/src/app/core/core.schemas.ts deleted file mode 100644 index c6d52b76..00000000 --- a/src/app/core/core.schemas.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const SearchBodySchema = { - type: 'object', - properties: { - s: { - type: 'string', - description: 'Optional value to search against selected fields.' - }, - q: { - type: 'object', - description: - 'Structured search object for matching database records. Typically supports MongoDB queries.' - } - }, - description: 'Criteria used for searching records' -} as const; - -export const PagingQueryStringSchema = { - type: 'object', - properties: { - page: { type: 'integer', description: 'Page number', examples: [0] }, - size: { - type: 'integer', - description: 'Number of results to return (results per page)', - examples: [20] - }, - sort: { type: 'string', description: 'Field name to sort by' }, - dir: { - anyOf: [ - { type: 'string', enum: ['ASC', 'DESC'] }, - { type: 'integer', enum: [-1, 1] } - ], - description: 'Sort direction' - } - } -} as const; diff --git a/src/app/core/core.types.ts b/src/app/core/core.types.ts new file mode 100644 index 00000000..51848a40 --- /dev/null +++ b/src/app/core/core.types.ts @@ -0,0 +1,50 @@ +import { TSchema, Type } from '@fastify/type-provider-typebox'; +import { Types as MongooseTypes } from 'mongoose'; + +export const DateType = Type.Unsafe({ + type: 'string', + format: 'date' +}); + +export const DateTimeType = Type.Unsafe({ + type: 'string', + format: 'date-time' +}); + +export const ObjectIdType = Type.Unsafe({ + type: 'string' +}); + +export const IdParamsType = Type.Object({ + id: Type.String() +}); + +export const SearchBodyType = Type.Object({ + s: Type.String(), + q: Type.Object({}) +}); + +export const DirectionType = Type.Union([ + Type.Literal('ASC'), + Type.Literal('DESC'), + Type.Literal(1), + Type.Literal(-1) +]); + +export const PagingQueryStringType = Type.Partial( + Type.Object({ + page: Type.Integer(), + size: Type.Integer(), + sort: Type.String(), + dir: DirectionType + }) +); + +export const PagingResultsType = (T: T) => + Type.Object({ + pageNumber: Type.Integer(), + pageSize: Type.Integer(), + totalPages: Type.Integer(), + totalSize: Type.Integer(), + elements: Type.Array(T) + }); diff --git a/src/app/core/export/export-config.controller.ts b/src/app/core/export/export-config.controller.ts index ab617cf4..7f201674 100644 --- a/src/app/core/export/export-config.controller.ts +++ b/src/app/core/export/export-config.controller.ts @@ -1,16 +1,17 @@ import os from 'os'; import { Readable, Transform } from 'stream'; -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { ExportColumnDef } from './export-config.model'; +import { ExportColumnDef, IExportConfig } from './export-config.model'; import exportConfigService from './export-config.service'; +import { ExportConfigType } from './export-config.types'; import { auditService, csvStream } from '../../../dependencies'; import { NotFoundError } from '../../common/errors'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/requestExport', @@ -18,54 +19,26 @@ export default function (_fastify: FastifyInstance) { description: 'Request to generate an export configuration in preparation to serve a file download soon.', tags: ['Export'], - body: { - type: 'object', - properties: { - type: { type: 'string' }, - config: { - type: 'object', - properties: { - col: { - type: 'object', - properties: { - key: { type: 'string' }, - title: { type: 'string' } - }, - required: ['key'] - }, - s: { type: 'string' }, - q: { type: 'object' }, - sort: { type: 'string' }, - dir: { - anyOf: [ - { type: 'string', enum: ['ASC', 'DESC'] }, - { type: 'integer', enum: [-1, 1] } - ] - } - } - } - }, - required: ['type'] - }, + body: ExportConfigType, response: { - 200: { - description: 'Successful response', - type: 'object', - properties: { - _id: { type: 'string' } - } - } + 200: Type.Object({ + _id: Type.String() + }) } }, handler: async function (req, reply) { const { q, ...config } = req.body.config; + + const exportConfig: Partial = { + type: req.body.type, + config: { ...config, q: '{}' } + }; if (q) { // Stringify the query JSON because '$' is reserved in Mongo. - config.q = JSON.stringify(req.body.config.q); + exportConfig.config.q = JSON.stringify(req.body.config.q); } - req.body.config = config; - const generatedConfig = await exportConfigService.create(req.body); + const generatedConfig = await exportConfigService.create(exportConfig); auditService .audit( diff --git a/src/app/core/export/export-config.service.ts b/src/app/core/export/export-config.service.ts index 909ea94e..2301d9b0 100644 --- a/src/app/core/export/export-config.service.ts +++ b/src/app/core/export/export-config.service.ts @@ -3,7 +3,8 @@ import { Types } from 'mongoose'; import { ExportConfig, ExportConfigDocument, - ExportConfigModel + ExportConfigModel, + IExportConfig } from './export-config.model'; class ExportConfigService { @@ -12,7 +13,7 @@ class ExportConfigService { /** * Generate a new ExportConfig document in the collection. */ - create(doc: unknown): Promise { + create(doc: Partial): Promise { const exportConfig = new this.model(doc); return exportConfig.save(); } diff --git a/src/app/core/export/export-config.types.ts b/src/app/core/export/export-config.types.ts new file mode 100644 index 00000000..a0bad9cb --- /dev/null +++ b/src/app/core/export/export-config.types.ts @@ -0,0 +1,19 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { DirectionType } from '../core.types'; + +export const ExportConfigType = Type.Object({ + type: Type.String(), + config: Type.Object({ + cols: Type.Array( + Type.Object({ + key: Type.String(), + title: Type.Optional(Type.String()) + }) + ), + s: Type.String(), + q: Type.Object({}, { additionalProperties: true }), + sort: Type.String(), + dir: DirectionType + }) +}); diff --git a/src/app/core/feedback/feedback-admin.controller.spec.ts b/src/app/core/feedback/feedback-admin.controller.spec.ts new file mode 100644 index 00000000..960385c2 --- /dev/null +++ b/src/app/core/feedback/feedback-admin.controller.spec.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; + +import { FastifyInstance } from 'fastify'; +import { assert as sinonAssert, createSandbox } from 'sinon'; + +import controller from './feedback-admin.controller'; +import { Feedback } from './feedback.model'; +import feedbackService from './feedback.service'; +import { fastifyTest } from '../../../spec/fastify'; +import { User, UserDocument } from '../user/user.model'; + +describe('Feedback Admin Controller', () => { + let sandbox; + + let app: FastifyInstance; + let user: UserDocument; + + before(async () => { + await User.deleteMany({}); + user = await new User({ + name: 'Test User', + username: 'test', + email: 'test@test.test', + organization: 'test', + provider: 'test', + roles: { + user: true, + admin: true + } + }).save(); + app = fastifyTest(controller, { + // logger: { level: 'debug' }, + user + }); + }); + after(async () => { + await app.close(); + await User.deleteMany({}); + }); + + beforeEach(async () => { + sandbox = createSandbox(); + await Feedback.deleteMany({}); + }); + + afterEach(async () => { + sandbox.restore(); + await Feedback.deleteMany({}); + }); + + describe('searchFeedback', () => { + it('search returns feedback', async () => { + sandbox.stub(feedbackService, 'search').resolves({}); + + const reply = await app.inject({ + method: 'POST', + url: '/admin/feedback', + payload: { + q: {}, + s: '' + } + }); + + sinonAssert.calledOnce(feedbackService.search); + + assert.equal( + reply.statusCode, + 200, + `route rejected with "${reply.payload}"` + ); + assert(reply.body); + }); + }); + + describe('updateFeedbackAssignee', () => { + it('assignee is updated', async () => { + const feedback = new Feedback({ + body: 'This is a test', + type: 'Bug', + url: 'http://localhost:3000/some-page?with=param', + creator: user._id + }); + await feedback.save(); + + const reply = await app.inject({ + method: 'PATCH', + url: `/admin/feedback/${feedback._id}/assignee`, + payload: { assignee: 'user' } + }); + + assert.equal( + reply.statusCode, + 200, + `route rejected with "${reply.payload}"` + ); + assert.equal(reply.json().assignee, 'user'); + assert(reply.body); + }); + }); + + describe('updateFeedbackStatus', () => { + it('status is updated', async () => { + const feedback = new Feedback({ + body: 'This is a test', + type: 'Bug', + url: 'http://localhost:3000/some-page?with=param', + creator: user._id + }); + await feedback.save(); + + const reply = await app.inject({ + method: 'PATCH', + url: `/admin/feedback/${feedback._id}/status`, + payload: { status: 'Closed' } + }); + + assert.equal( + reply.statusCode, + 200, + `route rejected with "${reply.payload}"` + ); + assert.equal(reply.json().status, 'Closed'); + assert(reply.body); + }); + }); +}); diff --git a/src/app/core/feedback/feedback-admin.controller.ts b/src/app/core/feedback/feedback-admin.controller.ts new file mode 100644 index 00000000..02c16f57 --- /dev/null +++ b/src/app/core/feedback/feedback-admin.controller.ts @@ -0,0 +1,177 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyInstance } from 'fastify'; +import _ from 'lodash'; +import { FilterQuery } from 'mongoose'; + +import { FeedbackDocument, FeedbackType, Statuses } from './feedback.model'; +import feedbackService from './feedback.service'; +import { + FeedbackSetAssigneeType, + FeedbackSetStatusType +} from './feedback.types'; +import { config } from '../../../dependencies'; +import { NotFoundError } from '../../common/errors'; +import { audit } from '../audit/audit.hooks'; +import { + IdParamsType, + PagingQueryStringType, + SearchBodyType +} from '../core.types'; +import { Callbacks } from '../export/callbacks'; +import * as exportConfigController from '../export/export-config.controller'; +import { loadExportConfigById } from '../export/export-config.controller'; +import { IExportConfig } from '../export/export-config.model'; +import { requireAdminAccess } from '../user/auth/auth.hooks'; + +export default function (_fastify: FastifyInstance) { + const fastify = _fastify.withTypeProvider(); + fastify.route({ + method: 'POST', + url: '/admin/feedback', + schema: { + description: 'returns feedback matching search criteria', + tags: ['Feedback'], + body: SearchBodyType, + querystring: PagingQueryStringType + }, + preValidation: requireAdminAccess, + handler: async function (req, reply) { + const results = await feedbackService.search( + req.query, + req.body.s, + req.body.q, + { + path: 'creator', + select: ['username', 'organization', 'name', 'email'] + } + ); + return reply.send(results); + } + }); + + fastify.route({ + method: 'PATCH', + url: '/admin/feedback/:id/status', + schema: { + description: 'Updates the status of the feedback with the supplied ID', + tags: ['Feedback'], + body: FeedbackSetStatusType, + params: IdParamsType, + response: { + 200: FeedbackType + } + }, + preValidation: requireAdminAccess, + handler: async function (req, reply) { + const populate = [ + { + path: 'creator', + select: ['username', 'organization', 'name', 'email'] + } + ]; + + const feedback = await feedbackService.read(req.params.id, populate); + if (!feedback) { + throw new NotFoundError('Could not find feedback'); + } + + const updatedFeedback = await feedbackService.updateFeedbackStatus( + feedback, + req.body.status as Statuses + ); + return reply.send(updatedFeedback); + }, + preSerialization: audit({ + message: 'Feedback status updated', + type: 'feedback', + action: 'update' + }) + }); + + fastify.route({ + method: 'PATCH', + url: '/admin/feedback/:id/assignee', + schema: { + description: ' Updates the assignee of the feedback with the supplied ID', + tags: ['Feedback'], + body: FeedbackSetAssigneeType, + params: IdParamsType, + response: { + 200: FeedbackType + } + }, + preValidation: requireAdminAccess, + handler: async function (req, reply) { + const populate = [ + { + path: 'creator', + select: ['username', 'organization', 'name', 'email'] + } + ]; + + const feedback = await feedbackService.read(req.params.id, populate); + if (!feedback) { + throw new NotFoundError('Could not find feedback'); + } + + const updatedFeedback = await feedbackService.updateFeedbackAssignee( + feedback, + req.body.assignee + ); + return reply.send(updatedFeedback); + }, + preSerialization: audit({ + message: 'Feedback assignee updated', + type: 'feedback', + action: 'update' + }) + }); + + fastify.route({ + method: 'GET', + url: '/admin/feedback/csv/:id', + schema: { + description: 'Export feedback as CSV file', + tags: ['Feedback'], + params: IdParamsType + }, + preValidation: requireAdminAccess, + preHandler: loadExportConfigById, + handler: function (req, reply) { + const exportConfig = req.exportConfig as IExportConfig; + const exportQuery = req.exportQuery as FilterQuery; + + const fileName = `${config.get('app.instanceName')}-${ + exportConfig.type + }.csv`; + + const columns = exportConfig.config.cols; + // Based on which columns are requested, handle property-specific behavior (ex. callbacks for the + // CSV service to make booleans and dates more human-readable) + columns.forEach((col) => { + col.title = col.title ?? _.capitalize(col.key); + + switch (col.key) { + case 'created': + case 'updated': + col.callback = Callbacks.isoDateString; + break; + } + }); + + const cursor = feedbackService.cursorSearch( + exportConfig.config, + exportConfig.config.s, + exportQuery, + { + path: 'creator', + select: ['username', 'organization', 'name', 'email'] + } + ); + + exportConfigController.exportCSV(req, reply, fileName, columns, cursor); + + return reply; + } + }); +} diff --git a/src/app/core/feedback/feedback.controller.spec.ts b/src/app/core/feedback/feedback.controller.spec.ts index 062713a1..4dea881f 100644 --- a/src/app/core/feedback/feedback.controller.spec.ts +++ b/src/app/core/feedback/feedback.controller.spec.ts @@ -3,44 +3,55 @@ import assert from 'node:assert/strict'; import { FastifyInstance } from 'fastify'; import { assert as sinonAssert, createSandbox } from 'sinon'; -import feedbackController from './feedback.controller'; +import controller from './feedback.controller'; import { Feedback } from './feedback.model'; import feedbackService from './feedback.service'; import { auditService } from '../../../dependencies'; import { fastifyTest } from '../../../spec/fastify'; +import { User, UserDocument } from '../user/user.model'; describe('Feedback Controller', () => { let sandbox; let app: FastifyInstance; - - before(() => { - app = fastifyTest(feedbackController, { - logger: { level: 'debug' }, - user: { - roles: { - user: true, - admin: true - } + let user: UserDocument; + + before(async () => { + await User.deleteMany({}); + user = await new User({ + name: 'Test User', + username: 'test', + email: 'test@test.test', + organization: 'test', + provider: 'test', + roles: { + user: true, + admin: true } + }).save(); + app = fastifyTest(controller, { + // logger: { level: 'debug' }, + user }); }); - after(() => { - app.close(); + after(async () => { + await app.close(); + await User.deleteMany({}); }); - beforeEach(() => { + beforeEach(async () => { sandbox = createSandbox(); + await Feedback.deleteMany({}); }); - afterEach(() => { + afterEach(async () => { sandbox.restore(); + await Feedback.deleteMany({}); }); describe('submitFeedback', () => { it(`should submit feedback successfully`, async () => { sandbox.stub(auditService, 'audit').resolves({ audit: {} }); - sandbox.stub(feedbackService, 'create').resolves(new Feedback()); sandbox.stub(feedbackService, 'sendFeedbackEmail').resolves(); const reply = await app.inject({ @@ -53,72 +64,14 @@ describe('Feedback Controller', () => { } }); - sinonAssert.calledOnce(feedbackService.create); sinonAssert.calledOnce(auditService.audit); - assert.equal(reply.statusCode, 200); + assert.equal( + reply.statusCode, + 200, + `route rejected with "${reply.payload}"` + ); assert(reply.body); }); - - describe('searchFeedback', () => { - it('search returns feedback', async () => { - sandbox.stub(feedbackService, 'search').resolves({}); - - const reply = await app.inject({ - method: 'POST', - url: '/admin/feedback', - payload: {} - }); - - sinonAssert.calledOnce(feedbackService.search); - - assert.equal(reply.statusCode, 200); - assert(reply.body); - }); - }); - - describe('updateFeedbackAssignee', () => { - it('assignee is updated', async () => { - sandbox.stub(feedbackService, 'read').resolves({}); - sandbox.stub(feedbackService, 'updateFeedbackAssignee').resolves({}); - - const reply = await app.inject({ - method: 'PATCH', - url: '/admin/feedback/1/assignee', - payload: { assignee: 'user' } - }); - - sinonAssert.calledOnceWithExactly( - feedbackService.updateFeedbackAssignee, - {}, - 'user' - ); - - assert.equal(reply.statusCode, 200); - assert(reply.body); - }); - }); - - describe('updateFeedbackStatus', () => { - it('status is updated', async () => { - sandbox.stub(feedbackService, 'read').resolves({}); - sandbox.stub(feedbackService, 'updateFeedbackStatus').resolves({}); - - const reply = await app.inject({ - method: 'PATCH', - url: '/admin/feedback/1/status', - payload: { status: 'Closed' } - }); - - sinonAssert.calledOnceWithExactly( - feedbackService.updateFeedbackStatus, - {}, - 'Closed' - ); - - assert.equal(reply.statusCode, 200); - assert(reply.body); - }); - }); }); }); diff --git a/src/app/core/feedback/feedback.controller.ts b/src/app/core/feedback/feedback.controller.ts index 554ffa8e..d7fde876 100644 --- a/src/app/core/feedback/feedback.controller.ts +++ b/src/app/core/feedback/feedback.controller.ts @@ -1,57 +1,24 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; -import _ from 'lodash'; -import { FilterQuery } from 'mongoose'; -import { FeedbackDocument, Statuses } from './feedback.model'; +import { FeedbackType } from './feedback.model'; import feedbackService from './feedback.service'; -import { config, utilService } from '../../../dependencies'; -import { NotFoundError } from '../../common/errors'; +import { CreateFeedbackType } from './feedback.types'; +import { utilService } from '../../../dependencies'; import { audit } from '../audit/audit.hooks'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; -import { Callbacks } from '../export/callbacks'; -import * as exportConfigController from '../export/export-config.controller'; -import { loadExportConfigById } from '../export/export-config.controller'; -import { IExportConfig } from '../export/export-config.model'; -import { requireLogin, requireAdminAccess } from '../user/auth/auth.hooks'; +import { requireLogin } from '../user/auth/auth.hooks'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/feedback', schema: { description: 'Submit feedback to the system', tags: ['Feedback'], - body: { - type: 'object', - properties: { - body: { - type: 'string', - title: 'Body', - description: 'Body of the feedback', - examples: ['This application is great!'] - }, - type: { - type: 'string', - title: 'Type', - description: 'type/category of the feedback', - examples: ['general feedback'] - }, - url: { - type: 'string', - title: 'URL', - description: 'url from which the feedback was submitted', - examples: ['http://localhost/#/home'] - }, - classification: { - type: 'string', - title: 'Classification', - description: 'Classification level of the feedback', - examples: ['class1'] - } - }, - required: ['body', 'type', 'url'] + body: CreateFeedbackType, + response: { + 200: FeedbackType } }, preValidation: requireLogin, @@ -71,169 +38,4 @@ export default function (_fastify: FastifyInstance) { action: 'create' }) }); - - fastify.route({ - method: 'POST', - url: '/admin/feedback', - schema: { - description: 'returns feedback matching search criteria', - tags: ['Feedback'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema - }, - preValidation: requireAdminAccess, - handler: async function (req, reply) { - const results = await feedbackService.search( - req.query, - req.body.s, - req.body.q, - { - path: 'creator', - select: ['username', 'organization', 'name', 'email'] - } - ); - return reply.send(results); - } - }); - - fastify.route({ - method: 'PATCH', - url: '/admin/feedback/:id/status', - schema: { - description: 'Updates the status of the feedback with the supplied ID', - tags: ['Feedback'], - body: { - type: 'object', - properties: { - status: { type: 'string', enum: ['New', 'Open', 'Closed'] } - }, - required: ['status'] - }, - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - preValidation: requireAdminAccess, - handler: async function (req, reply) { - const populate = [ - { - path: 'creator', - select: ['username', 'organization', 'name', 'email'] - } - ]; - - const feedback = await feedbackService.read(req.params.id, populate); - if (!feedback) { - throw new NotFoundError('Could not find feedback'); - } - - const updatedFeedback = await feedbackService.updateFeedbackStatus( - feedback, - req.body.status as Statuses - ); - return reply.send(updatedFeedback); - }, - preSerialization: audit({ - message: 'Feedback status updated', - type: 'feedback', - action: 'update' - }) - }); - - fastify.route({ - method: 'PATCH', - url: '/admin/feedback/:id/assignee', - schema: { - description: ' Updates the assignee of the feedback with the supplied ID', - tags: ['Feedback'], - body: { - type: 'object', - properties: { - assignee: { type: 'string' } - }, - required: ['assignee'] - }, - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - preValidation: requireAdminAccess, - handler: async function (req, reply) { - const populate = [ - { - path: 'creator', - select: ['username', 'organization', 'name', 'email'] - } - ]; - - const feedback = await feedbackService.read(req.params.id, populate); - if (!feedback) { - throw new NotFoundError('Could not find feedback'); - } - - const updatedFeedback = await feedbackService.updateFeedbackAssignee( - feedback, - req.body.assignee - ); - return reply.send(updatedFeedback); - }, - preSerialization: audit({ - message: 'Feedback assignee updated', - type: 'feedback', - action: 'update' - }) - }); - - fastify.route({ - method: 'GET', - url: '/admin/feedback/csv/:id', - schema: { - description: 'Export feedback as CSV file', - tags: ['Feedback'] - }, - preValidation: requireAdminAccess, - preHandler: loadExportConfigById, - handler: function (req, reply) { - const exportConfig = req.exportConfig as IExportConfig; - const exportQuery = req.exportQuery as FilterQuery; - - const fileName = `${config.get('app.instanceName')}-${ - exportConfig.type - }.csv`; - - const columns = exportConfig.config.cols; - // Based on which columns are requested, handle property-specific behavior (ex. callbacks for the - // CSV service to make booleans and dates more human-readable) - columns.forEach((col) => { - col.title = col.title ?? _.capitalize(col.key); - - switch (col.key) { - case 'created': - case 'updated': - col.callback = Callbacks.isoDateString; - break; - } - }); - - const cursor = feedbackService.cursorSearch( - exportConfig.config, - exportConfig.config.s, - exportQuery, - { - path: 'creator', - select: ['username', 'organization', 'name', 'email'] - } - ); - - exportConfigController.exportCSV(req, reply, fileName, columns, cursor); - } - }); } diff --git a/src/app/core/feedback/feedback.model.ts b/src/app/core/feedback/feedback.model.ts index a43d8485..00d66d70 100644 --- a/src/app/core/feedback/feedback.model.ts +++ b/src/app/core/feedback/feedback.model.ts @@ -1,4 +1,5 @@ -import { HydratedDocument, model, Model, Schema, Types } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { HydratedDocument, model, Model, Schema } from 'mongoose'; import { config } from '../../../dependencies'; import getterPlugin from '../../common/mongoose/getter.plugin'; @@ -10,6 +11,7 @@ import { textSearchPlugin, TextSearchable } from '../../common/mongoose/text-search.plugin'; +import { DateTimeType, ObjectIdType } from '../core.types'; export enum Statuses { New = 'New', @@ -17,21 +19,22 @@ export enum Statuses { Closed = 'Closed' } -export interface IFeedback { - _id: string; - body: string; - type: string; - url: string; - os: string; - browser: string; - classification: string; - status: Statuses; - assignee: string; - - creator: Types.ObjectId; - created: Date; - updated: Date; -} +export const FeedbackType = Type.Object({ + _id: ObjectIdType, + body: Type.String(), + type: Type.String(), + url: Type.String(), + os: Type.Optional(Type.String()), + browser: Type.Optional(Type.String()), + classification: Type.Optional(Type.String()), + status: Type.Enum(Statuses), + assignee: Type.Optional(Type.String()), + creator: ObjectIdType, + created: DateTimeType, + updated: DateTimeType +}); + +export type IFeedback = Static; export interface IFeedbackMethods { auditCopy(): Record; diff --git a/src/app/core/feedback/feedback.types.ts b/src/app/core/feedback/feedback.types.ts new file mode 100644 index 00000000..8b82d3e1 --- /dev/null +++ b/src/app/core/feedback/feedback.types.ts @@ -0,0 +1,14 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { FeedbackType } from './feedback.model'; + +export const FeedbackSetAssigneeType = Type.Pick(FeedbackType, ['assignee']); + +export const FeedbackSetStatusType = Type.Pick(FeedbackType, ['status']); + +export const CreateFeedbackType = Type.Pick(FeedbackType, [ + 'body', + 'type', + 'url', + 'classification' +]); diff --git a/src/app/core/messages/dismissed-message.model.ts b/src/app/core/messages/dismissed-message.model.ts index ce989d84..24ee4f57 100644 --- a/src/app/core/messages/dismissed-message.model.ts +++ b/src/app/core/messages/dismissed-message.model.ts @@ -1,16 +1,20 @@ -import { HydratedDocument, model, Model, Schema, Types } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { HydratedDocument, model, Model, Schema } from 'mongoose'; import { config } from '../../../dependencies'; import getterPlugin from '../../common/mongoose/getter.plugin'; import { Paginateable } from '../../common/mongoose/paginate.plugin'; import { TextSearchable } from '../../common/mongoose/text-search.plugin'; +import { DateTimeType, ObjectIdType } from '../core.types'; -export interface IDismissedMessage { - _id: Types.ObjectId; - messageId: Types.ObjectId; - userId: Types.ObjectId; - created: Date; -} +export const DismissedMessageType = Type.Object({ + _id: ObjectIdType, + messageId: ObjectIdType, + userId: ObjectIdType, + created: DateTimeType +}); + +export type IDismissedMessage = Static; export interface IDismissedMessageMethods { auditCopy(): Record; diff --git a/src/app/core/messages/message-admin.controller.ts b/src/app/core/messages/message-admin.controller.ts new file mode 100644 index 00000000..5bed7c1d --- /dev/null +++ b/src/app/core/messages/message-admin.controller.ts @@ -0,0 +1,107 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyInstance } from 'fastify'; + +import { loadMessageById } from './message.hooks'; +import { MessageType } from './message.model'; +import { CreateMessageType } from './message.types'; +import messageService from './messages.service'; +import { audit, auditTrackBefore } from '../audit/audit.hooks'; +import { IdParamsType } from '../core.types'; +import { requireAdminAccess } from '../user/auth/auth.hooks'; + +export default function (_fastify: FastifyInstance) { + const fastify = _fastify.withTypeProvider(); + fastify.route({ + method: 'POST', + url: '/admin/message', + schema: { + description: 'Create a new message', + tags: ['Messages'], + body: CreateMessageType, + response: { + 200: MessageType + } + }, + preValidation: requireAdminAccess, + handler: async function (req, reply) { + const message = await messageService.create(req.user, req.body); + + // Publish the message + messageService.publishMessage(message).then(); + + return reply.send(message); + }, + preSerialization: audit({ + message: 'message created', + type: 'message', + action: 'create' + }) + }); + + fastify.route({ + method: 'GET', + url: '/admin/message/:id', + schema: { + description: '', + tags: ['Messages'], + params: IdParamsType, + response: { + 200: MessageType + } + }, + preValidation: requireAdminAccess, + // eslint-disable-next-line require-await + handler: async function (req, reply) { + return reply.send(req.message); + } + }); + + fastify.route({ + method: 'POST', + url: '/admin/message/:id', + schema: { + description: 'Update message details', + tags: ['Messages'], + body: MessageType, + params: IdParamsType, + response: { + 200: MessageType + } + }, + preValidation: requireAdminAccess, + preHandler: [loadMessageById, auditTrackBefore('message')], + handler: async function (req, reply) { + const result = await messageService.update(req.message, req.body); + return reply.send(result); + }, + preSerialization: audit({ + message: 'message updated', + type: 'message', + action: 'update' + }) + }); + + fastify.route({ + method: 'DELETE', + url: '/admin/message/:id', + schema: { + description: '', + tags: ['Messages'], + params: IdParamsType, + response: { + 200: MessageType + } + }, + preValidation: requireAdminAccess, + preHandler: loadMessageById, + handler: async function (req, reply) { + const result = await messageService.delete(req.message); + return reply.send(result); + }, + preSerialization: audit({ + message: 'message deleted', + type: 'message', + action: 'delete' + }) + }); +} diff --git a/src/app/core/messages/message.controller.ts b/src/app/core/messages/message.controller.ts index a1438210..8a8362be 100644 --- a/src/app/core/messages/message.controller.ts +++ b/src/app/core/messages/message.controller.ts @@ -1,23 +1,22 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; -import { FastifyInstance, FastifyRequest } from 'fastify'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyInstance } from 'fastify'; +import { DismissMessagesType } from './message.types'; import messageService from './messages.service'; import { auditService } from '../../../dependencies'; -import { NotFoundError } from '../../common/errors'; -import { audit, auditTrackBefore } from '../audit/audit.hooks'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; -import { requireAccess, requireAdminAccess } from '../user/auth/auth.hooks'; +import { PagingQueryStringType, SearchBodyType } from '../core.types'; +import { requireAccess } from '../user/auth/auth.hooks'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/messages', schema: { - description: '', + description: 'Return messages matching search criteria', tags: ['Messages'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAccess, handler: async function (req, reply) { @@ -27,16 +26,7 @@ export default function (_fastify: FastifyInstance) { req.body.q ); - // Create the return copy of the messages - const mappedResults = { - pageNumber: results.pageNumber, - pageSize: results.pageSize, - totalPages: results.totalPages, - totalSize: results.totalSize, - elements: results.elements.map((element) => element.fullCopy()) - }; - - return reply.send(mappedResults); + return reply.send(results); } }); @@ -60,17 +50,7 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Dismiss messages', tags: ['Messages'], - body: { - type: 'object', - properties: { - messageIds: { - type: 'array', - items: { - type: 'string' - } - } - } - } + body: DismissMessagesType }, preValidation: requireAccess, handler: async function (req, reply) { @@ -95,110 +75,4 @@ export default function (_fastify: FastifyInstance) { return reply.send(dismissedMessages); } }); - - fastify.route({ - method: 'POST', - url: '/admin/message', - schema: { - description: 'Create a new message', - tags: ['Messages'] - }, - preValidation: requireAdminAccess, - handler: async function (req, reply) { - const message = await messageService.create(req.user, req.body); - - // Publish the message - messageService.publishMessage(message).then(); - - return reply.send(message); - }, - preSerialization: audit({ - message: 'message created', - type: 'message', - action: 'create' - }) - }); - - fastify.route({ - method: 'GET', - url: '/admin/message/:id', - schema: { - description: '', - tags: ['Messages'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - preValidation: requireAdminAccess, - handler: function (req, reply) { - return reply.send(req.message); - } - }); - - fastify.route({ - method: 'POST', - url: '/admin/message/:id', - schema: { - description: 'Update message details', - tags: ['Messages'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - preValidation: requireAdminAccess, - preHandler: [loadMessageById, auditTrackBefore('message')], - handler: async function (req, reply) { - const result = await messageService.update(req.message, req.body); - return reply.send(result); - }, - preSerialization: audit({ - message: 'message updated', - type: 'message', - action: 'update' - }) - }); - - fastify.route({ - method: 'DELETE', - url: '/admin/message/:id', - schema: { - description: '', - tags: ['Messages'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] - } - }, - preValidation: requireAdminAccess, - preHandler: loadMessageById, - handler: async function (req, reply) { - const result = await messageService.delete(req.message); - return reply.send(result); - }, - preSerialization: audit({ - message: 'message deleted', - type: 'message', - action: 'delete' - }) - }); -} - -async function loadMessageById(req: FastifyRequest) { - const id = req.params['id']; - - req.message = await messageService.read(id); - if (!req.message) { - throw new NotFoundError(`Failed to load message: ${id}`); - } } diff --git a/src/app/core/messages/message.hooks.ts b/src/app/core/messages/message.hooks.ts new file mode 100644 index 00000000..f8562264 --- /dev/null +++ b/src/app/core/messages/message.hooks.ts @@ -0,0 +1,13 @@ +import { FastifyRequest } from 'fastify'; + +import messageService from './messages.service'; +import { NotFoundError } from '../../common/errors'; + +export async function loadMessageById(req: FastifyRequest) { + const id = req.params['id']; + + req.message = await messageService.read(id); + if (!req.message) { + throw new NotFoundError(`Failed to load message: ${id}`); + } +} diff --git a/src/app/core/messages/message.model.ts b/src/app/core/messages/message.model.ts index c6e6aa4f..1284705a 100644 --- a/src/app/core/messages/message.model.ts +++ b/src/app/core/messages/message.model.ts @@ -1,4 +1,5 @@ -import { Schema, model, Types, HydratedDocument, Model } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { Schema, model, HydratedDocument, Model } from 'mongoose'; import getterPlugin from '../../common/mongoose/getter.plugin'; import { @@ -9,6 +10,7 @@ import { textSearchPlugin, TextSearchable } from '../../common/mongoose/text-search.plugin'; +import { DateTimeType, ObjectIdType } from '../core.types'; export enum MessageTypes { INFO = 'INFO', @@ -17,16 +19,18 @@ export enum MessageTypes { MOTD = 'MOTD' } -export interface IMessage { - _id: Types.ObjectId; - type: MessageTypes; - title: string; - body: string; - ackRequired: boolean; - creator: Types.ObjectId; - created: Date; - updated: Date; -} +export const MessageType = Type.Object({ + _id: ObjectIdType, + type: Type.Enum(MessageTypes), + title: Type.String(), + body: Type.String(), + ackRequired: Type.Boolean(), + creator: ObjectIdType, + created: DateTimeType, + updated: DateTimeType +}); + +export type IMessage = Static; export interface IMessageMethods { auditCopy(): Record; @@ -107,10 +111,6 @@ MessageSchema.index({ title: 'text', body: 'text', type: 'text' }); */ MessageSchema.methods.auditCopy = function (): Record { - return this.fullCopy(); -}; - -MessageSchema.methods.fullCopy = function () { const message: Record = {}; message.type = this.type; message.title = this.title; diff --git a/src/app/core/messages/message.types.ts b/src/app/core/messages/message.types.ts new file mode 100644 index 00000000..8700dfeb --- /dev/null +++ b/src/app/core/messages/message.types.ts @@ -0,0 +1,13 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { MessageType } from './message.model'; + +export const DismissMessagesType = Type.Object({ + messageIds: Type.Array(MessageType.properties._id) +}); + +export const CreateMessageType = Type.Pick(MessageType, [ + 'type', + 'title', + 'body' +]); diff --git a/src/app/core/messages/messages.service.spec.ts b/src/app/core/messages/messages.service.spec.ts index 3ea299ee..d403d386 100644 --- a/src/app/core/messages/messages.service.spec.ts +++ b/src/app/core/messages/messages.service.spec.ts @@ -66,7 +66,7 @@ describe('Messages Service:', () => { let messages = await messagesService.getRecentMessages(user._id); await messagesService.dismissMessages( - messages.splice(0, dismissCount).map((m) => m._id.toString()), + messages.splice(0, dismissCount).map((m) => m._id), user ); diff --git a/src/app/core/messages/messages.service.ts b/src/app/core/messages/messages.service.ts index 9306a191..aeff5d39 100644 --- a/src/app/core/messages/messages.service.ts +++ b/src/app/core/messages/messages.service.ts @@ -27,7 +27,7 @@ class MessagesService { private dismissedModel: DismissedMessageModel ) {} - create(user: UserDocument, doc: unknown): Promise { + create(user: UserDocument, doc: Partial): Promise { const message = new this.model(doc); message.creator = user._id; @@ -48,7 +48,10 @@ class MessagesService { .exec(); } - update(document: MessageDocument, obj: unknown): Promise { + update( + document: MessageDocument, + obj: Partial + ): Promise { document.set(obj); return document.save(); } @@ -103,18 +106,16 @@ class MessagesService { this.getDismissedMessages(userId) ]); - const filteredMessages = allMessages.filter((message) => { + return allMessages.filter((message) => { const isDismissed = dismissedMessages.some((dismissed) => dismissed.messageId.equals(message._id) ); return !isDismissed; }); - - return filteredMessages; } dismissMessages( - messageIds: string[], + messageIds: Types.ObjectId[], user: UserDocument ): Promise> { const dismissals = messageIds.map((messageId) => diff --git a/src/app/core/metrics/metrics.controller.ts b/src/app/core/metrics/metrics.controller.ts index 837789df..13b56014 100644 --- a/src/app/core/metrics/metrics.controller.ts +++ b/src/app/core/metrics/metrics.controller.ts @@ -1,10 +1,8 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; import { FastifyInstance } from 'fastify'; import { metricsLogger } from '../../../lib/logger'; -export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); +export default function (fastify: FastifyInstance) { fastify.route({ method: 'GET', url: '/client-metrics', diff --git a/src/app/core/notifications/notification.controller.ts b/src/app/core/notifications/notification.controller.ts index 2ed909b0..a57ef319 100644 --- a/src/app/core/notifications/notification.controller.ts +++ b/src/app/core/notifications/notification.controller.ts @@ -1,12 +1,14 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; +import { FilterQuery } from 'mongoose'; +import { INotification } from './notification.model'; import notificationsService from './notification.service'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; +import { PagingQueryStringType, SearchBodyType } from '../core.types'; import { requireAccess } from '../user/auth/auth.hooks'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/notifications', @@ -14,13 +16,13 @@ export default function (_fastify: FastifyInstance) { hide: true, description: '', tags: ['Notifications'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAccess, handler: async function (req, reply) { // Get search and query parameters - const query = req.body.q ?? {}; + const query: FilterQuery = req.body.q ?? {}; // Always need to filter by user making the service call query.user = req.user._id; diff --git a/src/app/core/notifications/notification.model.ts b/src/app/core/notifications/notification.model.ts index fc6e9dc3..1b403ec0 100644 --- a/src/app/core/notifications/notification.model.ts +++ b/src/app/core/notifications/notification.model.ts @@ -1,4 +1,5 @@ -import { HydratedDocument, model, Model, Schema, Types } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { HydratedDocument, model, Model, Schema } from 'mongoose'; import { config } from '../../../dependencies'; import getterPlugin from '../../common/mongoose/getter.plugin'; @@ -6,17 +7,20 @@ import { paginatePlugin, Paginateable } from '../../common/mongoose/paginate.plugin'; +import { DateTimeType, ObjectIdType } from '../core.types'; /** * Notification Schema */ -export interface INotification { - _id: Types.ObjectId; - user: Types.ObjectId; - created: Date; - notificationType: string; -} +export const NotificationType = Type.Object({ + _id: ObjectIdType, + user: ObjectIdType, + created: DateTimeType, + notificationType: Type.String() +}); + +export type INotification = Static; export interface INotificationMethods { auditCopy(): Record; diff --git a/src/app/core/teams/team-members.controller.ts b/src/app/core/teams/team-members.controller.ts new file mode 100644 index 00000000..6eb2d153 --- /dev/null +++ b/src/app/core/teams/team-members.controller.ts @@ -0,0 +1,199 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyInstance } from 'fastify'; + +import { requireTeamAdminRole, requireTeamMemberRole } from './team-auth.hooks'; +import { TeamRoles } from './team-role.model'; +import { loadTeamById, loadTeamMemberById } from './team.hooks'; +import { + AddTeamMembersType, + IdAndMemberIdParamsType, + TeamMemberRoleType +} from './team.types'; +import teamsService from './teams.service'; +import { utilService, auditService } from '../../../dependencies'; +import { + IdParamsType, + PagingQueryStringType, + SearchBodyType +} from '../core.types'; +import { + requireAccess, + requireAdminRole, + requireAny +} from '../user/auth/auth.hooks'; +import userService from '../user/user.service'; + +export default function (_fastify: FastifyInstance) { + const fastify = _fastify.withTypeProvider(); + fastify.route({ + method: 'PUT', + url: '/team/:id/members', + schema: { + description: 'Adds members to a Team', + tags: ['Team'], + body: AddTeamMembersType, + params: IdParamsType + }, + preValidation: [ + requireAccess, + requireAny(requireAdminRole, requireTeamAdminRole) + ], + preHandler: loadTeamById, + handler: async function (req, reply) { + await Promise.all( + req.body.newMembers + .filter((member) => null != member._id) + .map(async (member) => { + const user = await userService.read(member._id); + if (null != user) { + await teamsService.addMemberToTeam(user, req.team, member.role); + return auditService.audit( + `team ${member.role} added`, + 'team-role', + 'user add', + req, + req.team.auditCopyTeamMember(user, member.role) + ); + } + }) + ); + return reply.send(); + } + }); + + fastify.route({ + method: 'POST', + url: '/team/:id/members', + schema: { + description: 'Searches for members of a Team', + tags: ['Team'], + body: SearchBodyType, + querystring: PagingQueryStringType, + params: IdParamsType + }, + preValidation: [ + requireAccess, + requireAny(requireAdminRole, requireTeamMemberRole) + ], + preHandler: loadTeamById, + handler: async function (req, reply) { + // Get search and query parameters + const search = req.body.s ?? ''; + const query = teamsService.updateMemberFilter( + utilService.toMongoose(req.body.q ?? {}), + req.team + ); + + const results = await userService.searchUsers(req.query, query, search); + + // Create the return copy of the messages + const mappedResults = { + pageNumber: results.pageNumber, + pageSize: results.pageSize, + totalPages: results.totalPages, + totalSize: results.totalSize, + elements: results.elements.map((element) => { + return { + ...element.filteredCopy(), + teams: element.teams.filter((team) => team._id.equals(req.team._id)) + }; + }) + }; + + return reply.send(mappedResults); + } + }); + + fastify.route({ + method: 'POST', + url: '/team/:id/member/:memberId', + schema: { + description: 'Adds a member to a Team', + tags: ['Team'], + body: TeamMemberRoleType, + params: IdAndMemberIdParamsType + }, + preValidation: [ + requireAccess, + requireAny(requireAdminRole, requireTeamAdminRole) + ], + preHandler: [loadTeamById, loadTeamMemberById], + handler: async function (req, reply) { + const role: TeamRoles = req.body.role ?? TeamRoles.Member; + + await teamsService.addMemberToTeam(req.userParam, req.team, role); + + // Audit the member add request + await auditService.audit( + `team ${role} added`, + 'team-role', + 'user add', + req, + req.team.auditCopyTeamMember(req.userParam, role) + ); + + return reply.send(); + } + }); + + fastify.route({ + method: 'DELETE', + url: '/team/:id/member/:memberId', + schema: { + description: 'Deletes a member from a Team', + tags: ['Team'], + params: IdAndMemberIdParamsType + }, + preValidation: [ + requireAccess, + requireAny(requireAdminRole, requireTeamAdminRole) + ], + preHandler: [loadTeamById, loadTeamMemberById], + handler: async function (req, reply) { + await teamsService.removeMemberFromTeam(req.userParam, req.team); + + // Audit the user remove + await auditService.audit( + 'team member removed', + 'team-role', + 'user remove', + req, + req.team.auditCopyTeamMember(req.userParam) + ); + + return reply.send(); + } + }); + + fastify.route({ + method: 'POST', + url: '/team/:id/member/:memberId/role', + schema: { + description: `Updates a member's role in a team`, + tags: ['Team'], + body: TeamMemberRoleType, + params: IdAndMemberIdParamsType + }, + preValidation: [ + requireAccess, + requireAny(requireAdminRole, requireTeamAdminRole) + ], + preHandler: [loadTeamById, loadTeamMemberById], + handler: async function (req, reply) { + const role: TeamRoles = req.body.role || TeamRoles.Member; + + await teamsService.updateMemberRole(req.userParam, req.team, role); + + // Audit the member update request + await auditService.audit( + `team role changed to ${role}`, + 'team-role', + 'user add', + req, + req.team.auditCopyTeamMember(req.userParam, role) + ); + + return reply.send(); + } + }); +} diff --git a/src/app/core/teams/team-role.model.ts b/src/app/core/teams/team-role.model.ts index ee80e6a2..51c4b770 100644 --- a/src/app/core/teams/team-role.model.ts +++ b/src/app/core/teams/team-role.model.ts @@ -1,6 +1,8 @@ -import { model, Schema, Types } from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import { model, Schema } from 'mongoose'; import getterPlugin from '../../common/mongoose/getter.plugin'; +import { ObjectIdType } from '../core.types'; export enum TeamRoles { Admin = 'admin', @@ -30,23 +32,13 @@ export const TeamRoleMinimumWithAccess = TeamRoles.Member; */ export const TeamRoleImplicit = TeamRoles.Member; -export interface ITeamRole { - _id: Types.ObjectId; - role: TeamRoles; -} +export const TeamRoleType = Type.Object({ + _id: ObjectIdType, + role: Type.Enum(TeamRoles) +}); + +export type ITeamRole = Static; -/** - * @swagger - * components: - * schemas: - * TeamRole: - * type: object - * properties: - * _id: - * type: string - * role: - * type: string - */ export const TeamRoleSchema = new Schema( { _id: { diff --git a/src/app/core/teams/team.hooks.ts b/src/app/core/teams/team.hooks.ts new file mode 100644 index 00000000..b782160a --- /dev/null +++ b/src/app/core/teams/team.hooks.ts @@ -0,0 +1,27 @@ +import { FastifyRequest } from 'fastify'; + +import teamsService from './teams.service'; +import { NotFoundError } from '../../common/errors'; +import userService from '../user/user.service'; + +export async function loadTeamById(req: FastifyRequest) { + const id = req.params['id']; + const populate = [ + { path: 'parentObj', select: ['name'] }, + { path: 'ancestorObjs', select: ['name'] } + ]; + + req.team = await teamsService.read(id, populate); + if (!req.team) { + throw new NotFoundError('Could not find team'); + } +} + +export async function loadTeamMemberById(req: FastifyRequest) { + const id = req.params['memberId']; + req.userParam = await userService.read(id); + + if (!req.userParam) { + throw new Error('Failed to load team member'); + } +} diff --git a/src/app/core/teams/team.model.ts b/src/app/core/teams/team.model.ts index 6caf68fa..0813bfb1 100644 --- a/src/app/core/teams/team.model.ts +++ b/src/app/core/teams/team.model.ts @@ -1,10 +1,5 @@ -import mongoose, { - model, - HydratedDocument, - Model, - Schema, - Types -} from 'mongoose'; +import { Static, Type } from '@fastify/type-provider-typebox'; +import mongoose, { model, HydratedDocument, Model, Schema } from 'mongoose'; import { utilService } from '../../../dependencies'; import { @@ -16,21 +11,25 @@ import { Paginateable, paginatePlugin } from '../../common/mongoose/paginate.plugin'; +import { DateTimeType, ObjectIdType } from '../core.types'; import { UserDocument } from '../user/user.model'; -export interface ITeam { - name: string; - description: string; - created: Date; - updated: Date; - creator: Types.ObjectId; - creatorName: string; - implicitMembers: boolean; - requiresExternalRoles: string[]; - requiresExternalTeams: string[]; - parent: Types.ObjectId; - ancestors: Types.ObjectId[]; -} +export const TeamType = Type.Object({ + _id: ObjectIdType, + name: Type.String(), + description: Type.Optional(Type.String()), + created: DateTimeType, + updated: DateTimeType, + creator: ObjectIdType, + creatorName: Type.String(), + implicitMembers: Type.Optional(Type.Boolean()), + requiresExternalRoles: Type.Optional(Type.Array(Type.String())), + requiresExternalTeams: Type.Optional(Type.Array(Type.String())), + parent: Type.Optional(ObjectIdType), + ancestors: Type.Optional(Type.Array(ObjectIdType)) +}); + +export type ITeam = Static; export interface ITeamMethods { auditCopy(): Record; diff --git a/src/app/core/teams/team.types.ts b/src/app/core/teams/team.types.ts new file mode 100644 index 00000000..f3b7a952 --- /dev/null +++ b/src/app/core/teams/team.types.ts @@ -0,0 +1,50 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { TeamRoles } from './team-role.model'; +import { TeamType } from './team.model'; +import { IdParamsType, ObjectIdType } from '../core.types'; + +export const CreateTeamType = Type.Object({ + team: Type.Pick(TeamType, [ + 'name', + 'description', + 'implicitMembers', + 'requiresExternalRoles', + 'requiresExternalTeams', + 'parent' + ]), + firstAdmin: Type.Optional(ObjectIdType) +}); + +export const UpdateTeamType = Type.Pick(CreateTeamType, [ + 'name', + 'description', + 'requiresExternalRoles', + 'requiresExternalTeams' +]); + +export const AddTeamMembersType = Type.Object({ + newMembers: Type.Array( + Type.Object({ + _id: ObjectIdType, + role: Type.Enum(TeamRoles) + }) + ) +}); + +export const TeamMemberRoleType = Type.Object({ + role: Type.Enum(TeamRoles) +}); + +export const RequestTeamType = Type.Object({ + org: Type.String(), + aoi: Type.String(), + description: Type.String() +}); + +export const IdAndMemberIdParamsType = Type.Composite([ + IdParamsType, + Type.Object({ + memberId: Type.String() + }) +]); diff --git a/src/app/core/teams/teams.controller.ts b/src/app/core/teams/teams.controller.ts index a952c53f..dcecbb70 100644 --- a/src/app/core/teams/teams.controller.ts +++ b/src/app/core/teams/teams.controller.ts @@ -1,36 +1,37 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; -import { FastifyInstance, FastifyRequest } from 'fastify'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyInstance } from 'fastify'; import { requireTeamAdminRole, requireTeamMemberRole } from './team-auth.hooks'; -import { TeamRoles } from './team-role.model'; +import { loadTeamById } from './team.hooks'; +import { TeamType } from './team.model'; +import { CreateTeamType, RequestTeamType, UpdateTeamType } from './team.types'; import teamsService from './teams.service'; import { utilService, auditService } from '../../../dependencies'; -import { NotFoundError } from '../../common/errors'; import { audit, auditTrackBefore } from '../audit/audit.hooks'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; +import { + IdParamsType, + PagingQueryStringType, + PagingResultsType, + SearchBodyType +} from '../core.types'; import { requireAccess, requireAdminRole, requireAny, requireEditorAccess } from '../user/auth/auth.hooks'; -import userService from '../user/user.service'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/team', schema: { description: 'Creates a new Team', tags: ['Team'], - body: { - type: 'object', - properties: { - team: { type: 'object' }, - firstAdmin: { type: 'string' } - }, - required: ['team'] + body: CreateTeamType, + response: { + 200: TeamType } }, preValidation: requireEditorAccess, @@ -55,8 +56,11 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Returns teams that match the search criteria', tags: ['Team'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType, + response: { + 200: PagingResultsType(TeamType) + } }, preValidation: requireAccess, handler: async function (req, reply) { @@ -79,13 +83,10 @@ export default function (_fastify: FastifyInstance) { url: '/team/:id', schema: { description: 'Gets the details of a Team', + params: IdParamsType, tags: ['Team'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] + response: { + 200: TeamType } }, preValidation: [ @@ -93,7 +94,8 @@ export default function (_fastify: FastifyInstance) { requireAny(requireAdminRole, requireTeamMemberRole) ], preHandler: loadTeamById, - handler: function (req, reply) { + // eslint-disable-next-line require-await + handler: async function (req, reply) { return reply.send(req.team); } }); @@ -104,12 +106,10 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Updates the details of a Team', tags: ['Team'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] + params: IdParamsType, + body: UpdateTeamType, + response: { + 200: TeamType } }, preValidation: [ @@ -134,12 +134,9 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Deletes a Team', tags: ['Team'], - params: { - type: 'object', - properties: { - id: { type: 'string' } - }, - required: ['id'] + params: IdParamsType, + response: { + 200: TeamType } }, preValidation: [ @@ -165,7 +162,8 @@ export default function (_fastify: FastifyInstance) { hide: true, description: 'Requests access to a Team. Notifies team admins of the request', - tags: ['Team'] + tags: ['Team'], + params: IdParamsType }, preValidation: requireAccess, preHandler: loadTeamById, @@ -183,14 +181,7 @@ export default function (_fastify: FastifyInstance) { description: 'Requests a new Team. Notifies the team organization admin of the request.', tags: ['Team'], - body: { - type: 'object', - properties: { - org: { type: 'string' }, - aoi: { type: 'string' }, - description: { type: 'string' } - } - } + body: RequestTeamType }, preValidation: requireAccess, handler: async function (req, reply) { @@ -209,227 +200,4 @@ export default function (_fastify: FastifyInstance) { return reply.send(); } }); - - fastify.route({ - method: 'PUT', - url: '/team/:id/members', - schema: { - description: 'Adds members to a Team', - tags: ['Team'], - body: { - type: 'object', - properties: { - newMembers: { - type: 'array', - items: { - type: 'object', - properties: { - _id: { type: 'string' }, - role: { type: 'string', enum: Object.values(TeamRoles) } - }, - required: ['_id'] - } - } - }, - required: ['newMembers'] - } - }, - preValidation: [ - requireAccess, - requireAny(requireAdminRole, requireTeamAdminRole) - ], - preHandler: loadTeamById, - handler: async function (req, reply) { - await Promise.all( - req.body.newMembers - .filter((member) => null != member._id) - .map(async (member) => { - const user = await userService.read(member._id); - if (null != user) { - await teamsService.addMemberToTeam(user, req.team, member.role); - return auditService.audit( - `team ${member.role} added`, - 'team-role', - 'user add', - req, - req.team.auditCopyTeamMember(user, member.role) - ); - } - }) - ); - return reply.send(); - } - }); - - fastify.route({ - method: 'POST', - url: '/team/:id/members', - schema: { - description: 'Searches for members of a Team', - tags: ['Team'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema - }, - preValidation: [ - requireAccess, - requireAny(requireAdminRole, requireTeamMemberRole) - ], - preHandler: loadTeamById, - handler: async function (req, reply) { - // Get search and query parameters - const search = req.body.s ?? ''; - const query = teamsService.updateMemberFilter( - utilService.toMongoose(req.body.q ?? {}), - req.team - ); - - const results = await userService.searchUsers(req.query, query, search); - - // Create the return copy of the messages - const mappedResults = { - pageNumber: results.pageNumber, - pageSize: results.pageSize, - totalPages: results.totalPages, - totalSize: results.totalSize, - elements: results.elements.map((element) => { - return { - ...element.filteredCopy(), - teams: element.teams.filter((team) => team._id.equals(req.team._id)) - }; - }) - }; - - return reply.send(mappedResults); - } - }); - - fastify.route({ - method: 'POST', - url: '/team/:id/member/:memberId', - schema: { - description: 'Adds a member to a Team', - tags: ['Team'], - body: { - type: 'object', - properties: { - role: { - type: 'string', - enum: Object.values(TeamRoles) - } - }, - required: ['role'] - } - }, - preValidation: [ - requireAccess, - requireAny(requireAdminRole, requireTeamAdminRole) - ], - preHandler: [loadTeamById, loadTeamMemberById], - handler: async function (req, reply) { - const role: TeamRoles = req.body.role ?? TeamRoles.Member; - - await teamsService.addMemberToTeam(req.userParam, req.team, role); - - // Audit the member add request - await auditService.audit( - `team ${role} added`, - 'team-role', - 'user add', - req, - req.team.auditCopyTeamMember(req.userParam, role) - ); - - return reply.send(); - } - }); - - fastify.route({ - method: 'DELETE', - url: '/team/:id/member/:memberId', - schema: { - description: 'Deletes a member from a Team', - tags: ['Team'] - }, - preValidation: [ - requireAccess, - requireAny(requireAdminRole, requireTeamAdminRole) - ], - preHandler: [loadTeamById, loadTeamMemberById], - handler: async function (req, reply) { - await teamsService.removeMemberFromTeam(req.userParam, req.team); - - // Audit the user remove - await auditService.audit( - 'team member removed', - 'team-role', - 'user remove', - req, - req.team.auditCopyTeamMember(req.userParam) - ); - - return reply.send(); - } - }); - - fastify.route({ - method: 'POST', - url: '/team/:id/member/:memberId/role', - schema: { - description: `Updates a member's role in a team`, - tags: ['Team'], - body: { - type: 'object', - properties: { - role: { - type: 'string', - enum: Object.values(TeamRoles) - } - }, - required: ['role'] - } - }, - preValidation: [ - requireAccess, - requireAny(requireAdminRole, requireTeamAdminRole) - ], - preHandler: [loadTeamById, loadTeamMemberById], - handler: async function (req, reply) { - const role: TeamRoles = req.body.role || TeamRoles.Member; - - await teamsService.updateMemberRole(req.userParam, req.team, role); - - // Audit the member update request - await auditService.audit( - `team role changed to ${role}`, - 'team-role', - 'user add', - req, - req.team.auditCopyTeamMember(req.userParam, role) - ); - - return reply.send(); - } - }); -} - -async function loadTeamById(req: FastifyRequest) { - const id = req.params['id']; - const populate = [ - { path: 'parentObj', select: ['name'] }, - { path: 'ancestorObjs', select: ['name'] } - ]; - - req.team = await teamsService.read(id, populate); - if (!req.team) { - throw new NotFoundError('Could not find team'); - } -} - -async function loadTeamMemberById(req: FastifyRequest) { - const id = req.params['memberId']; - req.userParam = await userService.read(id); - - if (!req.userParam) { - throw new Error('Failed to load team member'); - } } diff --git a/src/app/core/teams/teams.service.ts b/src/app/core/teams/teams.service.ts index f74b9121..7cb632fb 100644 --- a/src/app/core/teams/teams.service.ts +++ b/src/app/core/teams/teams.service.ts @@ -105,7 +105,7 @@ class TeamsService { * @param obj The obj with updated fields * @returns Returns a promise that resolves if team is successfully updated, and rejects otherwise */ - update(document: TeamDocument, obj: unknown): Promise { + update(document: TeamDocument, obj: Partial): Promise { // Copy in the fields that can be changed by the user copyMutableFields(document, obj); diff --git a/src/app/core/user/auth/auth.controller.ts b/src/app/core/user/auth/auth.controller.ts index bb1f82a2..0cae34a6 100644 --- a/src/app/core/user/auth/auth.controller.ts +++ b/src/app/core/user/auth/auth.controller.ts @@ -1,6 +1,7 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; +import { SigninType, SignupType, TokenParamsType } from './auth.types'; import userAuthService from './user-authentication.service'; import userAuthorizationService from './user-authorization.service'; import userPasswordService from './user-password.service'; @@ -11,21 +12,14 @@ import userEmailService from '../user-email.service'; import { User } from '../user.model'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/auth/signin', schema: { tags: ['Auth'], description: 'authenticates the user', - body: { - type: 'object', - properties: { - username: { type: 'string' }, - password: { type: 'string' } - }, - required: ['username', 'password'] - } + body: SigninType }, handler: async function (req, reply) { const user = await userAuthService.authenticateAndLogin(req, reply); @@ -60,18 +54,8 @@ export default function (_fastify: FastifyInstance) { url: '/auth/signup', schema: { tags: ['Auth'], - description: 'Signs out the user.', - body: { - type: 'object', - properties: { - name: { type: 'string' }, - username: { type: 'string' }, - organization: { type: 'string' }, - email: { type: 'string' }, - password: { type: 'string' } - }, - required: ['name', 'username', 'password'] - } + description: 'Signs up the user.', + body: SignupType }, handler: async function (req, reply) { const newUser = new User(req.body); @@ -101,13 +85,7 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['Auth'], description: 'Initiates password reset', - body: { - type: 'object', - properties: { - username: { type: 'string' } - }, - required: ['username'] - } + body: Type.Object({ username: Type.String() }) }, handler: async function (req, reply) { const user = await userPasswordService.initiatePasswordReset( @@ -126,13 +104,7 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['Auth'], description: 'Validates password reset token', - params: { - type: 'object', - properties: { - token: { type: 'string' } - }, - required: ['token'] - } + params: TokenParamsType }, handler: async function (req, reply) { const user = await userPasswordService.findUserForActiveToken( @@ -151,20 +123,8 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['Auth'], description: 'Resets password', - params: { - type: 'object', - properties: { - token: { type: 'string' } - }, - required: ['token'] - }, - body: { - type: 'object', - properties: { - password: { type: 'string' } - }, - required: ['password'] - } + body: Type.Object({ password: Type.String() }), + params: TokenParamsType }, handler: async function (req, reply) { const user = await userPasswordService.resetPasswordForToken( diff --git a/src/app/core/user/auth/auth.types.ts b/src/app/core/user/auth/auth.types.ts new file mode 100644 index 00000000..5d38890c --- /dev/null +++ b/src/app/core/user/auth/auth.types.ts @@ -0,0 +1,15 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { UserType } from '../user.model'; + +export const SigninType = Type.Pick(UserType, ['username', 'password']); + +export const SignupType = Type.Pick(UserType, [ + 'username', + 'password', + 'name', + 'organization', + 'email' +]); + +export const TokenParamsType = Type.Object({ token: Type.String() }); diff --git a/src/app/core/user/eua/eua.controller.ts b/src/app/core/user/eua/eua.controller.ts index 032d0a5c..cfb83ab6 100644 --- a/src/app/core/user/eua/eua.controller.ts +++ b/src/app/core/user/eua/eua.controller.ts @@ -1,21 +1,25 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance, FastifyRequest } from 'fastify'; import euaService from './eua.service'; import { audit, auditTrackBefore } from '../../audit/audit.hooks'; -import { PagingQueryStringSchema, SearchBodySchema } from '../../core.schemas'; +import { + IdParamsType, + PagingQueryStringType, + SearchBodyType +} from '../../core.types'; import { requireAdminAccess, requireLogin } from '../auth/auth.hooks'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/euas', schema: { description: 'Returns EUAs matching search criteria', tags: ['EUA'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAdminAccess, handler: async function (req, reply) { @@ -85,7 +89,8 @@ export default function (_fastify: FastifyInstance) { url: '/eua/:id', schema: { description: 'Retrieve EUA details', - tags: ['EUA'] + tags: ['EUA'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadEuaById, @@ -99,7 +104,8 @@ export default function (_fastify: FastifyInstance) { url: '/eua/:id', schema: { description: 'Update EUA details', - tags: ['Eua'] + tags: ['Eua'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: [loadEuaById, auditTrackBefore('euaParam')], @@ -119,7 +125,8 @@ export default function (_fastify: FastifyInstance) { url: '/eua/:id', schema: { description: '', - tags: ['EUA'] + tags: ['EUA'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadEuaById, @@ -139,7 +146,8 @@ export default function (_fastify: FastifyInstance) { url: '/eua/:id/publish', schema: { description: '', - tags: ['EUA'] + tags: ['EUA'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadEuaById, diff --git a/src/app/core/user/admin/user-admin.controller.ts b/src/app/core/user/user-admin.controller.ts similarity index 77% rename from src/app/core/user/admin/user-admin.controller.ts rename to src/app/core/user/user-admin.controller.ts index 96131b53..17f5de72 100644 --- a/src/app/core/user/admin/user-admin.controller.ts +++ b/src/app/core/user/user-admin.controller.ts @@ -1,32 +1,37 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance } from 'fastify'; import _ from 'lodash'; import { FilterQuery } from 'mongoose'; -import { auditService, config, utilService } from '../../../../dependencies'; -import { logger } from '../../../../lib/logger'; -import { PagingQueryStringSchema, SearchBodySchema } from '../../core.schemas'; -import { Callbacks } from '../../export/callbacks'; -import * as exportConfigController from '../../export/export-config.controller'; -import { loadExportConfigById } from '../../export/export-config.controller'; -import { IExportConfig } from '../../export/export-config.model'; -import { requireAdminAccess } from '../auth/auth.hooks'; -import userAuthorizationService from '../auth/user-authorization.service'; -import userEmailService from '../user-email.service'; -import { loadUserById } from '../user.controller'; -import { Roles, User, UserDocument } from '../user.model'; -import userService from '../user.service'; +import { requireAdminAccess } from './auth/auth.hooks'; +import userAuthorizationService from './auth/user-authorization.service'; +import userEmailService from './user-email.service'; +import { loadUserById } from './user.controller'; +import { Roles, User, UserDocument } from './user.model'; +import userService from './user.service'; +import { CreateUserType, AdminUpdateUserType } from './user.types'; +import { auditService, config, utilService } from '../../../dependencies'; +import { logger } from '../../../lib/logger'; +import { + IdParamsType, + PagingQueryStringType, + SearchBodyType +} from '../core.types'; +import { Callbacks } from '../export/callbacks'; +import * as exportConfigController from '../export/export-config.controller'; +import { loadExportConfigById } from '../export/export-config.controller'; +import { IExportConfig } from '../export/export-config.model'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'POST', url: '/admin/users', schema: { description: 'Returns users that match the search criteria', tags: ['User'], - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAdminAccess, handler: async function (req, reply) { @@ -64,7 +69,8 @@ export default function (_fastify: FastifyInstance) { url: '/admin/user/:id', schema: { description: '', - tags: ['User'] + tags: ['User'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadUserById, @@ -79,19 +85,8 @@ export default function (_fastify: FastifyInstance) { schema: { description: '', tags: ['User'], - body: { - type: 'object', - properties: { - name: { type: 'string' }, - organization: { type: 'string' }, - email: { type: 'string' }, - phone: { type: 'string' }, - username: { type: 'string' }, - password: { type: 'string' }, - roles: { type: 'object' }, - bypassAccessCheck: { type: 'boolean' } - } - } + body: AdminUpdateUserType, + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadUserById, @@ -143,7 +138,8 @@ export default function (_fastify: FastifyInstance) { url: '/admin/user/:id', schema: { description: '', - tags: ['User'] + tags: ['User'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadUserById, @@ -170,19 +166,15 @@ export default function (_fastify: FastifyInstance) { schema: { description: '', tags: ['User'], - body: { - type: 'object', - properties: { - field: { type: 'string' }, - query: { type: 'object' } - }, - required: ['field'] - } + body: Type.Object({ + field: Type.String(), + query: Type.Optional(Type.Object({}, { additionalProperties: true })) + }) }, preValidation: requireAdminAccess, handler: async function (req, reply) { const field = req.body.field; - const query = req.body.query; + const query = req.body.query ?? {}; logger.debug('Querying Users for %s', field); const proj = { [field]: 1 }; @@ -206,19 +198,7 @@ export default function (_fastify: FastifyInstance) { schema: { description: 'Create a new user', tags: ['User'], - body: { - type: 'object', - properties: { - name: { type: 'string' }, - organization: { type: 'string' }, - email: { type: 'string' }, - phone: { type: 'string' }, - username: { type: 'string' }, - password: { type: 'string' }, - roles: { type: 'object' }, - bypassAccessCheck: { type: 'boolean' } - } - } + body: CreateUserType }, preValidation: requireAdminAccess, handler: async function (req, reply) { @@ -267,7 +247,8 @@ export default function (_fastify: FastifyInstance) { url: '/admin/users/csv/:id', schema: { description: 'Export users as CSV file', - tags: ['User'] + tags: ['User'], + params: IdParamsType }, preValidation: requireAdminAccess, preHandler: loadExportConfigById, @@ -328,6 +309,8 @@ export default function (_fastify: FastifyInstance) { ); exportConfigController.exportCSV(req, reply, fileName, columns, cursor); + + return reply; } }); } diff --git a/src/app/core/user/user.controller.ts b/src/app/core/user/user.controller.ts index bb67bb6d..70f27e25 100644 --- a/src/app/core/user/user.controller.ts +++ b/src/app/core/user/user.controller.ts @@ -1,23 +1,31 @@ -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyInstance, FastifyRequest } from 'fastify'; import _ from 'lodash'; import { requireAccess, requireLogin } from './auth/auth.hooks'; import userAuthorizationService from './auth/user-authorization.service'; import userService from './user.service'; +import { UpdateUserType, UserReturnType } from './user.types'; import { auditService } from '../../../dependencies'; import { BadRequestError } from '../../common/errors'; -import { PagingQueryStringSchema, SearchBodySchema } from '../core.schemas'; +import { + IdParamsType, + PagingQueryStringType, + SearchBodyType +} from '../core.types'; import teamService from '../teams/teams.service'; export default function (_fastify: FastifyInstance) { - const fastify = _fastify.withTypeProvider(); + const fastify = _fastify.withTypeProvider(); fastify.route({ method: 'GET', url: '/user/me', schema: { tags: ['User'], - description: 'Returns details about the authenticated user.' + description: 'Returns details about the authenticated user.', + response: { + 200: UserReturnType + } }, preValidation: requireLogin, handler: async function (req, reply) { @@ -37,18 +45,7 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['User'], description: 'Updates details about the authenticated user.', - body: { - type: 'object', - properties: { - name: { type: 'string' }, - organization: { type: 'string' }, - email: { type: 'string' }, - username: { type: 'string' }, - password: { type: 'string' }, - currentPassword: { type: 'string' } - }, - required: ['name', 'organization', 'email', 'username'] - } + body: UpdateUserType }, preValidation: requireLogin, handler: async function (req, reply) { @@ -107,7 +104,8 @@ export default function (_fastify: FastifyInstance) { url: '/user/:id', schema: { tags: ['User'], - description: '' + description: 'Returns details for the specified user', + params: IdParamsType }, preValidation: requireAccess, preHandler: loadUserById, @@ -121,10 +119,8 @@ export default function (_fastify: FastifyInstance) { url: '/user-preference', schema: { tags: ['User'], - description: '', - body: { - type: 'object' - } + description: 'Updates user preferences for the current user', + body: Type.Object({}, { additionalProperties: true }) }, preValidation: requireLogin, handler: async function (req, reply) { @@ -139,8 +135,8 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['User'], description: 'Returns users matching search criteria', - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAccess, handler: async function (req, reply) { @@ -166,8 +162,8 @@ export default function (_fastify: FastifyInstance) { schema: { tags: ['User'], description: '', - body: SearchBodySchema, - querystring: PagingQueryStringSchema + body: SearchBodyType, + querystring: PagingQueryStringType }, preValidation: requireAccess, preHandler: loadUserById, diff --git a/src/app/core/user/user.model.spec.ts b/src/app/core/user/user.model.spec.ts index da0d5a42..2691f925 100644 --- a/src/app/core/user/user.model.spec.ts +++ b/src/app/core/user/user.model.spec.ts @@ -11,7 +11,7 @@ function clearDatabase() { return Promise.all([User.deleteMany({}).exec()]); } -function userSpec(key) { +function userSpec(key: string) { return { name: `${key} Name`, organization: `${key} Organization`, @@ -250,7 +250,7 @@ describe('User Model:', () => { assert.deepStrictEqual(audit, testUserWithDNandIP); }); - it('should ignore dn if no dn or providerdata is present, or flatten value', () => { + it('should ignore dn if no dn or provider data is present, or flatten value', () => { const testUserNoDn = new User({ name: 'test', providerData: { notdn: false } @@ -286,33 +286,29 @@ describe('User Model:', () => { assert.equal(result.length, 1); }); - it('should not be able to save with the same username', () => { + it('should not be able to save with the same username', async () => { const validUser = new User(userSpec('valid')); - return validUser - .save() - .then(() => { - assert.fail(); - }) - .catch((err) => { - assert(err); - }); + try { + await validUser.save(); + assert.fail(); + } catch (err) { + assert(err); + } }); // Testing missing fields ['name', 'organization', 'email', 'username'].forEach((field) => { // Creating a test case for each field - it(`should fail to save if missing field: '${field}'`, () => { + it(`should fail to save if missing field: '${field}'`, async () => { const u = new User(userSpec(`missing_${field}`)); u[field] = undefined; - return u - .save() - .then(() => { - assert.fail(); - }) - .catch((err) => { - assert(err); - }); + try { + await u.save(); + assert.fail(); + } catch (err) { + assert(err); + } }); }); }); diff --git a/src/app/core/user/user.model.ts b/src/app/core/user/user.model.ts index fbc0d47a..e817052e 100644 --- a/src/app/core/user/user.model.ts +++ b/src/app/core/user/user.model.ts @@ -1,13 +1,8 @@ -import crypto, { BinaryLike } from 'crypto'; +import crypto from 'crypto'; +import { type Static, Type } from '@fastify/type-provider-typebox'; import _ from 'lodash'; -import mongoose, { - HydratedDocument, - model, - Model, - Schema, - Types -} from 'mongoose'; +import mongoose, { HydratedDocument, model, Model, Schema } from 'mongoose'; import uniqueValidator from 'mongoose-unique-validator'; import { config, utilService as util } from '../../../dependencies'; @@ -24,7 +19,8 @@ import { TextSearchable, textSearchPlugin } from '../../common/mongoose/text-search.plugin'; -import { TeamRoles, TeamRoleSchema } from '../teams/team-role.model'; +import { DateTimeType, ObjectIdType } from '../core.types'; +import { TeamRoleSchema, TeamRoleType } from '../teams/team-role.model'; /** * Validation @@ -62,49 +58,54 @@ const roleObject = Roles.reduce( const roleSchemaDef = new mongoose.Schema(roleObject); -type UserRoles = { - user?: boolean; - editor?: boolean; - auditor?: boolean; - admin?: boolean; - machine?: boolean; -}; +const UserRolesType = Type.Object( + { + user: Type.Optional(Type.Boolean()), + editor: Type.Optional(Type.Boolean()), + auditor: Type.Optional(Type.Boolean()), + admin: Type.Optional(Type.Boolean()), + machine: Type.Optional(Type.Boolean()) + }, + { additionalProperties: true } +); -type UserTeam = { _id: Types.ObjectId; role: TeamRoles }; - -export interface IUser { - _id: Types.ObjectId; - name: string; - organization: string; - organizationLevels: Record; - email: string; - phone: string; - username: string; - password: string; - provider: string; - providerData: Record; - additionalProvidersData: Record; - roles: UserRoles; - localRoles?: UserRoles; - canProxy: boolean; - canMasquerade: boolean; - externalGroups: string[]; - externalRoles: string[]; - bypassAccessCheck: boolean; - updated: Date; - created: Date; - messagesAcknowledged: Date; - alertsViewed: Date; - resetPasswordToken: string; - resetPasswordExpires: Date; - acceptedEua: Date; - lastLogin: Date; - lastLoginWithAccess: Date; - newFeatureDismissed: Date; - preferences: Record; - salt: BinaryLike; - teams: UserTeam[]; -} +export const UserType = Type.Object({ + _id: ObjectIdType, + name: Type.String(), + organization: Type.String(), + organizationLevels: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + email: Type.String(), + phone: Type.Optional(Type.String()), + username: Type.String(), + password: Type.String(), + provider: Type.String(), + providerData: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + additionalProvidersData: Type.Optional( + Type.Record(Type.String(), Type.Unknown()) + ), + roles: UserRolesType, + localRoles: Type.Optional(UserRolesType), + canProxy: Type.Optional(Type.Boolean()), + canMasquerade: Type.Optional(Type.Boolean()), + externalGroups: Type.Optional(Type.Array(Type.String())), + externalRoles: Type.Optional(Type.Array(Type.String())), + bypassAccessCheck: Type.Boolean(), + updated: DateTimeType, + created: DateTimeType, + messagesAcknowledged: Type.Optional(Type.Union([DateTimeType, Type.Null()])), + alertsViewed: Type.Optional(DateTimeType), + resetPasswordToken: Type.Optional(Type.String()), + resetPasswordExpires: Type.Optional(DateTimeType), + acceptedEua: Type.Optional(Type.Union([DateTimeType, Type.Null()])), + lastLogin: Type.Optional(Type.Union([DateTimeType, Type.Null()])), + lastLoginWithAccess: Type.Optional(Type.Union([DateTimeType, Type.Null()])), + newFeatureDismissed: Type.Optional(Type.Union([DateTimeType, Type.Null()])), + preferences: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + salt: Type.Optional(Type.String()), + teams: Type.Optional(Type.Array(TeamRoleType)) +}); + +export type IUser = Static; interface IUserMethods { fullCopy(): IUser; @@ -278,10 +279,7 @@ UserSchema.index({ name: 'text', email: 'text', username: 'text' }); const preSave = function (this: UserDocument, next) { // If the password is modified and it is valid, then re- salt/hash it if (this.isModified('password') && validatePassword(this, this.password)) { - this.salt = Buffer.from( - crypto.randomBytes(16).toString('base64'), - 'base64' - ); + this.salt = crypto.randomBytes(16).toString('base64'); this.password = this.hashPassword(this.password); } diff --git a/src/app/core/user/user.types.ts b/src/app/core/user/user.types.ts new file mode 100644 index 00000000..3300fe24 --- /dev/null +++ b/src/app/core/user/user.types.ts @@ -0,0 +1,28 @@ +import { Type } from '@fastify/type-provider-typebox'; + +import { UserType } from './user.model'; + +export const UserReturnType = Type.Omit(UserType, [ + 'password', + 'salt', + 'resetPasswordToken', + 'resetPasswordExpires' +]); + +export const UpdateUserType = Type.Object({ + name: Type.String(), + organization: Type.String(), + email: Type.String({ format: 'email' }), + username: Type.String(), + password: Type.Optional(Type.String()), + currentPassword: Type.Optional(Type.String()) +}); + +export const CreateUserType = Type.Omit(UserType, [ + '_id', + 'provider', + 'updated', + 'created' +]); + +export const AdminUpdateUserType = Type.Partial(CreateUserType); diff --git a/src/lib/fastify.ts b/src/lib/fastify.ts index b18a4c3b..edfb99a8 100644 --- a/src/lib/fastify.ts +++ b/src/lib/fastify.ts @@ -9,7 +9,6 @@ import { Authenticator } from '@fastify/passport'; import fastifySession from '@fastify/session'; import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUi from '@fastify/swagger-ui'; -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; import config from 'config'; import MongoStore from 'connect-mongo'; import { fastify, FastifyInstance } from 'fastify'; @@ -24,7 +23,7 @@ const baseApiPath = '/api'; export async function init(db: Mongoose) { const app = fastify({ logger: config.get('fastifyLogging') - }).withTypeProvider(); + }); // Configure compression await app.register(fastifyCompress);