From 78a052bc834012d50e36866da5cee02427d5eed8 Mon Sep 17 00:00:00 2001 From: Kyle Morel Date: Fri, 15 Dec 2023 11:09:36 -0800 Subject: [PATCH 1/8] userService implementation user and identity_provider created if necessary in db upon authentication --- app/app.ts | 12 +- app/config/custom-environment-variables.json | 5 + app/config/default.json | 5 + app/package.json | 4 +- app/src/components/utils.ts | 27 +- app/src/controllers/chefs.ts | 3 +- app/src/middleware/authentication.ts | 5 +- app/src/services/chefs.ts | 29 +- app/src/services/index.ts | 1 + app/src/services/user.ts | 571 ++++++++++--------- app/src/types/User.ts | 13 + app/src/types/UserSearchParameters.ts | 11 + app/src/types/index.ts | 7 + 13 files changed, 409 insertions(+), 284 deletions(-) create mode 100644 app/src/types/User.ts create mode 100644 app/src/types/UserSearchParameters.ts create mode 100644 app/src/types/index.ts diff --git a/app/app.ts b/app/app.ts index 1edecbc3..412567ba 100644 --- a/app/app.ts +++ b/app/app.ts @@ -11,7 +11,7 @@ import querystring from 'querystring'; import { name as appName, version as appVersion } from './package.json'; import { DEFAULTCORS } from './src/components/constants'; import { getLogger, httpLogger } from './src/components/log'; -import { getGitRevision, readIdpList } from './src/components/utils'; +import { getGitRevision, parseCSV, readIdpList } from './src/components/utils'; import v1Router from './src/routes/v1'; import type { Request, Response } from 'express'; @@ -30,7 +30,15 @@ app.use(compression()); app.use(cors(DEFAULTCORS)); app.use(express.json({ limit: config.get('server.bodyLimit') })); app.use(express.urlencoded({ extended: true })); -app.use(helmet()); +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + 'default-src': parseCSV(config.get('server.helmet.contentSecurityPolicy.defaultSrc')) + } + } + }) +); // Skip if running tests if (process.env.NODE_ENV !== 'test') { diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index 89d14efe..9dee6362 100644 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -24,6 +24,11 @@ } } }, + "helmet": { + "contentSecurityPolicy": { + "defaultSrc": "SERVER_HELMET_CONTENTSECURITYPOLICY_DEFAULTSRC" + } + }, "oidc": { "enabled": "SERVER_OIDC_ENABLED", "clientId": "SERVER_OIDC_CLIENTID", diff --git a/app/config/default.json b/app/config/default.json index a6e010c9..c27f3489 100644 --- a/app/config/default.json +++ b/app/config/default.json @@ -13,6 +13,11 @@ "poolMax": "10", "username": "app" }, + "helmet": { + "contentSecurityPolicy": { + "defaultSrc": "'self'" + } + }, "logLevel": "http", "port": "8080" } diff --git a/app/package.json b/app/package.json index be3cc849..555c9326 100644 --- a/app/package.json +++ b/app/package.json @@ -40,7 +40,9 @@ "migrate:up": "knex migrate:up", "postmigrate:up": "npm run prisma:sync", "seed": "knex seed:run", - "prisma:sync": "prisma db pull" + "prisma:sync": "prisma db pull", + "postprisma:sync": "npm run prisma:generate", + "prisma:generate": "prisma generate" }, "dependencies": { "@prisma/client": "^5.7.0", diff --git a/app/src/components/utils.ts b/app/src/components/utils.ts index c396314a..a3907d34 100644 --- a/app/src/components/utils.ts +++ b/app/src/components/utils.ts @@ -3,8 +3,7 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { getLogger } from './log'; -import { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig'; -import { YRN } from '../types/YRN'; +import { ChefsFormConfig, ChefsFormConfigData, YRN } from '../types'; const log = getLogger(module.filename); /** @@ -77,6 +76,30 @@ export function isTruthy(value: unknown) { return value === true || value === 1 || (isStr && trueStrings.includes(value.toLowerCase())); } +/** + * @function parseCSV + * Converts a comma separated value string into an array of string values + * @param {string} value The CSV string to parse + * @returns {string[]} An array of string values, or `value` if it is not a string + */ +export function parseCSV(value: string): Array { + return value.split(',').map((s) => s.trim()); +} + +/** + * @function parseIdentityKeyClaims + * Returns an array of strings representing potential identity key claims + * Array will always end with the last value as 'sub' + * @returns {string[]} An array of string values, or `value` if it is not a string + */ +export function parseIdentityKeyClaims(): Array { + const claims: Array = []; + if (config.has('server.oidc.identityKey')) { + claims.push(...parseCSV(config.get('server.oidc.identityKey'))); + } + return claims.concat('sub'); +} + /** * @function readIdpList * Acquires the list of identity providers to be used diff --git a/app/src/controllers/chefs.ts b/app/src/controllers/chefs.ts index d0908741..88128e65 100644 --- a/app/src/controllers/chefs.ts +++ b/app/src/controllers/chefs.ts @@ -6,8 +6,7 @@ import { IdentityProvider } from '../components/constants'; import type { NextFunction, Request, Response } from 'express'; import type { JwtPayload } from 'jsonwebtoken'; -import type { ChefsFormConfig, ChefsFormConfigData } from '../types/ChefsFormConfig'; -import type { ChefsSubmissionFormExport } from '../types/ChefsSubmissionFormExport'; +import type { ChefsFormConfig, ChefsFormConfigData, ChefsSubmissionFormExport } from '../types'; const controller = { getFormExport: async (req: Request, res: Response, next: NextFunction) => { diff --git a/app/src/middleware/authentication.ts b/app/src/middleware/authentication.ts index 5ac5aaad..9db79cd4 100644 --- a/app/src/middleware/authentication.ts +++ b/app/src/middleware/authentication.ts @@ -4,8 +4,9 @@ import config from 'config'; import jwt from 'jsonwebtoken'; import { AuthType } from '../components/constants'; +import { userService } from '../services'; -import type { CurrentUser } from '../types/CurrentUser'; +import type { CurrentUser } from '../types'; import type { NextFunction, Request, Response } from 'express'; /** @@ -54,7 +55,7 @@ export const currentUser = async (req: Request, res: Response, next: NextFunctio if (isValid) { currentUser.tokenPayload = typeof isValid === 'object' ? isValid : jwt.decode(bearerToken); - //await userService.login(currentUser.tokenPayload); + await userService.login(currentUser.tokenPayload as jwt.JwtPayload); } else { throw new Error('Invalid authorization token'); } diff --git a/app/src/services/chefs.ts b/app/src/services/chefs.ts index 19fa6a17..d312042c 100644 --- a/app/src/services/chefs.ts +++ b/app/src/services/chefs.ts @@ -7,7 +7,7 @@ import { NIL } from 'uuid'; import { fromYrn, getChefsApiKey, toYrn } from '../components/utils'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; -import type { ChefsSubmissionForm } from '../types/ChefsSubmissionForm'; +import type { ChefsSubmissionForm } from '../types'; const prisma = new PrismaClient(); @@ -93,6 +93,33 @@ const service = { } }, + updateSubmission: async (data: ChefsSubmissionForm) => { + try { + await prisma.submission.update({ + data: { + ...data, + addedToATS: fromYrn(data.addedToATS), + financiallySupported: fromYrn(data.financiallySupported), + updatedAai: fromYrn(data.updatedAai) + }, + where: { + submissionId: data.submissionId + } + }); + } catch (e: unknown) { + throw e; + } + }, + + getSubmissionStatus: async (formId: string, formSubmissionId: string) => { + try { + const response = await chefsAxios(formId).get(`submissions/${formSubmissionId}/status`); + return response.data; + } catch (e: unknown) { + throw e; + } + }, + updateSubmission: async (data: ChefsSubmissionForm) => { try { await prisma.submission.update({ diff --git a/app/src/services/index.ts b/app/src/services/index.ts index a8f25756..060b691d 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1 +1,2 @@ export { default as chefsService } from './chefs'; +export { default as userService } from './user'; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index 906906a6..427f2b6e 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -1,274 +1,297 @@ -// const { v4: uuidv4, NIL: SYSTEM_USER } = require('uuid'); - -// const { parseIdentityKeyClaims } = require('../components/utils'); - -// const { IdentityProvider, User } = require('../db/models'); -// const utils = require('../db/models/utils'); - -// /** -// * The User DB Service -// */ -// const service = { -// /** -// * @function _tokenToUser -// * Transforms JWT payload contents into a User Model object -// * @param {object} token The decoded JWT payload -// * @returns {object} An equivalent User model object -// */ -// _tokenToUser: (token) => { -// const identityId = parseIdentityKeyClaims() -// .map((idKey) => token[idKey]) -// .filter((claims) => claims) // Drop falsy values from array -// .concat(undefined)[0]; // Set undefined as last element of array - -// return { -// identityId: identityId, -// username: token.identity_provider_identity ? token.identity_provider_identity : token.preferred_username, -// firstName: token.given_name, -// fullName: token.name, -// lastName: token.family_name, -// email: token.email, -// idp: token.identity_provider -// }; -// }, - -// /** -// * @function createIdp -// * Create an identity provider record -// * @param {string} idp The identity provider code -// * @param {object} [etrx=undefined] An optional Objection Transaction object -// * @returns {Promise} The result of running the insert operation -// * @throws The error encountered upon db transaction failure -// */ -// createIdp: async (idp, etrx = undefined) => { -// let trx; -// try { -// trx = etrx ? etrx : await IdentityProvider.startTransaction(); - -// const obj = { -// idp: idp, -// createdBy: SYSTEM_USER -// }; - -// const response = await IdentityProvider.query(trx).insertAndFetch(obj); -// if (!etrx) await trx.commit(); -// return response; -// } catch (err) { -// if (!etrx && trx) await trx.rollback(); -// throw err; -// } -// }, - -// /** -// * @function createUser -// * Create a user DB record -// * @param {object} data Incoming user data -// * @param {object} [etrx=undefined] An optional Objection Transaction object -// * @returns {Promise} The result of running the insert operation -// * @throws The error encountered upon db transaction failure -// */ -// createUser: async (data, etrx = undefined) => { -// let trx; -// try { -// let response; -// trx = etrx ? etrx : await User.startTransaction(); - -// const exists = await User.query(trx).where({ identityId: data.identityId, idp: data.idp }).first(); - -// if (exists) { -// response = exists; -// } else { -// // else add new user -// if (data.idp) { -// // add idp if not in db -// const identityProvider = await service.readIdp(data.idp, trx); -// if (!identityProvider) await service.createIdp(data.idp, trx); -// } - -// response = await User.query(trx) -// .insert({ -// userId: uuidv4(), -// identityId: data.identityId, -// username: data.username, -// fullName: data.fullName, -// email: data.email, -// firstName: data.firstName, -// lastName: data.lastName, -// idp: data.idp, -// createdBy: data.userId -// }) -// .returning('*'); -// } - -// if (!etrx) await trx.commit(); -// return response; -// } catch (err) { -// if (!etrx && trx) await trx.rollback(); -// throw err; -// } -// }, - -// /** -// * @function getCurrentUserId -// * Gets userId (primary identifier of a user in COMS db) of currentUser. -// * if request is basic auth returns `defaultValue` -// * @param {object} identityId the identity field of the current user -// * @param {string} [defaultValue=undefined] An optional default return value -// * @returns {string} The current userId if applicable, or `defaultValue` -// */ -// getCurrentUserId: async (identityId, defaultValue = undefined) => { -// // TODO: Consider conditionally skipping when identityId is undefined? -// const user = await User.query().select('userId').where('identityId', identityId).first(); - -// return user && user.userId ? user.userId : defaultValue; -// }, - -// /** -// * @function listIdps -// * Lists all known identity providers -// * @param {boolean} [params.active] Optional boolean on user active status -// * @returns {Promise} The result of running the find operation -// */ -// listIdps: (params) => { -// return IdentityProvider.query().modify('filterActive', params.active).modify('orderDefault'); -// }, - -// /** -// * @function login -// * Parse the user token and update the user table if necessary -// * @param {object} token The decoded JWT token payload -// * @returns {Promise} The result of running the login operation -// */ -// login: async (token) => { -// const newUser = service._tokenToUser(token); -// // wrap with db transaction -// return await utils.trxWrapper(async (trx) => { -// // check if user exists in db -// const oldUser = await User.query(trx).where({ identityId: newUser.identityId, idp: newUser.idp }).first(); - -// if (!oldUser) { -// // Add user to system -// return await service.createUser(newUser, trx); -// } else { -// // Update user data if necessary -// return await service.updateUser(oldUser.userId, newUser, trx); -// } -// }); -// }, - -// /** -// * @function readIdp -// * Gets an identity provider record -// * @param {string} code The identity provider code -// * @returns {Promise} The result of running the find operation -// * @throws The error encountered upon db transaction failure -// */ -// readIdp: async (code, etrx = undefined) => { -// let trx; -// try { -// trx = etrx ? etrx : await IdentityProvider.startTransaction(); - -// const response = await IdentityProvider.query(trx).findById(code); - -// if (!etrx) await trx.commit(); -// return response; -// } catch (err) { -// if (!etrx && trx) await trx.rollback(); -// throw err; -// } -// }, - -// /** -// * @function readUser -// * Gets a user record -// * @param {string} userId The userId uuid -// * @returns {Promise} The result of running the find operation -// * @throws If no record is found -// */ -// readUser: (userId) => { -// return User.query().findById(userId).throwIfNotFound(); -// }, - -// /** -// * @function searchUsers -// * Search and filter for specific users -// * @param {string|string[]} [params.userId] Optional string or array of uuids representing the user subject -// * @param {string|string[]} [params.identityId] Optional string or array of uuids representing the user identity -// * @param {string|string[]} [params.idp] Optional string or array of identity providers -// * @param {string} [params.username] Optional username string to match on -// * @param {string} [params.email] Optional email string to match on -// * @param {string} [params.firstName] Optional firstName string to match on -// * @param {string} [params.fullName] Optional fullName string to match on -// * @param {string} [params.lastName] Optional lastName string to match on -// * @param {boolean} [params.active] Optional boolean on user active status -// * @param {string} [params.search] Optional search string to match on in username, email and fullName -// * @returns {Promise} The result of running the find operation -// */ -// searchUsers: (params) => { -// return User.query() -// .modify('filterUserId', params.userId) -// .modify('filterIdentityId', params.identityId) -// .modify('filterIdp', params.idp) -// .modify('filterUsername', params.username) -// .modify('filterEmail', params.email) -// .modify('filterFirstName', params.firstName) -// .modify('filterFullName', params.fullName) -// .modify('filterLastName', params.lastName) -// .modify('filterActive', params.active) -// .modify('filterSearch', params.search) -// .whereNotNull('identityId') -// .modify('orderLastFirstAscending'); -// }, - -// /** -// * @function updateUser -// * Updates a user record only if there are changed values -// * @param {string} userId The userId uuid -// * @param {object} data Incoming user data -// * @param {object} [etrx=undefined] An optional Objection Transaction object -// * @returns {Promise} The result of running the patch operation -// * @throws The error encountered upon db transaction failure -// */ -// updateUser: async (userId, data, etrx = undefined) => { -// let trx; -// try { -// // Check if any user values have changed -// const oldUser = await service.readUser(userId); -// const diff = Object.entries(data).some(([key, value]) => oldUser[key] !== value); - -// if (diff) { -// // Patch existing user -// trx = etrx ? etrx : await User.startTransaction(); - -// if (data.idp) { -// const identityProvider = await service.readIdp(data.idp, trx); -// if (!identityProvider) await service.createIdp(data.idp, trx); -// } - -// const obj = { -// identityId: data.identityId, -// username: data.username, -// fullName: data.fullName, -// email: data.email, -// firstName: data.firstName, -// lastName: data.lastName, -// idp: data.idp, -// updatedBy: data.userId -// }; - -// // TODO: add support for updating userId primary key in the event it changes -// const response = await User.query(trx).patchAndFetchById(userId, obj); -// if (!etrx) await trx.commit(); -// return response; -// } else { -// // Nothing to update -// return oldUser; -// } -// } catch (err) { -// if (!etrx && trx) await trx.rollback(); -// throw err; -// } -// } -// }; - -// export default service; +import jwt from 'jsonwebtoken'; +import { Prisma, PrismaClient } from '@prisma/client'; +import { v4, NIL } from 'uuid'; + +import { parseIdentityKeyClaims } from '../components/utils'; + +import type { User, UserSearchParameters } from '../types'; + +const prisma = new PrismaClient(); +const dbClient = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); + +/** + * The User DB Service + */ +const service = { + /** + * @function _tokenToUser + * Transforms JWT payload contents into a User Model object + * @param {object} token The decoded JWT payload + * @returns {object} An equivalent User model object + */ + _tokenToUser: (token: jwt.JwtPayload) => { + const identityId = parseIdentityKeyClaims() + .map((idKey) => token[idKey]) + .filter((claims) => claims) // Drop falsy values from array + .concat(undefined)[0]; // Set undefined as last element of array + + return { + identityId: identityId, + username: token.identity_provider_identity ? token.identity_provider_identity : token.preferred_username, + firstName: token.given_name, + fullName: token.name, + lastName: token.family_name, + email: token.email, + idp: token.identity_provider + }; + }, + + /** + * @function createIdp + * Create an identity provider record + * @param {string} idp The identity provider code + * @param {object} [etrx=undefined] An optional Prisma Transaction object + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createIdp: async (idp: string, etrx: Prisma.TransactionClient | undefined = undefined) => { + const obj = { + idp: idp, + createdBy: NIL + }; + + const response = dbClient(etrx).identity_provider.create({ data: obj }); + + return response; + }, + + /** + * @function createUser + * Create a user DB record + * @param {object} data Incoming user data + * @param {object} [etrx=undefined] An optional Prisma Transaction object + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createUser: async (data: User, etrx: Prisma.TransactionClient | undefined = undefined) => { + let response; + + // Logical function + const _createUser = async (data: User, trx: Prisma.TransactionClient) => { + const exists = await trx.user.findFirst({ + where: { + identityId: data.identityId, + idp: data.idp + } + }); + + if (exists) { + response = exists; + } else { + if (data.idp) { + const identityProvider = await service.readIdp(data.idp, trx); + if (!identityProvider) await service.createIdp(data.idp, trx); + } + + response = await trx.user.create({ + data: { + userId: v4(), + identityId: data.identityId, + username: data.username, + fullName: data.fullName, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + idp: data.idp, + createdBy: data.userId + } + }); + } + }; + + // Call with proper transaction + if (etrx) { + await _createUser(data, etrx); + } else { + await prisma.$transaction(async (trx) => { + await _createUser(data, trx); + }); + } + + return response; + }, + + /** + * @function getCurrentUserId + * Gets userId (primary identifier of a user in db) of currentUser. + * if request is basic auth returns `defaultValue` + * @param {object} identityId the identity field of the current user + * @param {string} [defaultValue=undefined] An optional default return value + * @returns {string} The current userId if applicable, or `defaultValue` + */ + getCurrentUserId: async (identityId: string, defaultValue = undefined) => { + // TODO: Consider conditionally skipping when identityId is undefined? + const user = await prisma.user.findFirst({ + where: { + identityId: identityId + } + }); + + return user && user.userId ? user.userId : defaultValue; + }, + + /** + * @function listIdps + * Lists all known identity providers + * @param {boolean} [active] Optional boolean on user active status + * @returns {Promise} The result of running the find operation + */ + listIdps: (active: boolean) => { + return prisma.identity_provider.findMany({ + where: { + active: active + } + }); + }, + + /** + * @function login + * Parse the user token and update the user table if necessary + * @param {object} token The decoded JWT token payload + * @returns {Promise} The result of running the login operation + */ + login: async (token: jwt.JwtPayload) => { + const newUser = service._tokenToUser(token); + + let response; + await prisma.$transaction(async (trx) => { + const oldUser = await trx.user.findFirst({ + where: { + identityId: newUser.identityId, + idp: newUser.idp + } + }); + + if (!oldUser) { + response = await service.createUser(newUser, trx); + } else { + response = await service.updateUser(oldUser.userId, newUser, trx); + } + }); + + return response; + }, + + /** + * @function readIdp + * Gets an identity provider record + * @param {string} code The identity provider code + * @param {object} [etrx=undefined] An optional Prisma Transaction object + * @returns {Promise} The result of running the find operation + * @throws The error encountered upon db transaction failure + */ + readIdp: async (code: string, etrx: Prisma.TransactionClient | undefined = undefined) => { + return await dbClient(etrx).identity_provider.findUnique({ + where: { + idp: code + } + }); + }, + + /** + * @function readUser + * Gets a user record + * @param {string} userId The userId uuid + * @returns {Promise} The result of running the find operation + * @throws If no record is found + */ + readUser: async (userId: string) => { + return await prisma.user.findUnique({ + where: { + userId: userId + } + }); + }, + + /** + * @function searchUsers + * Search and filter for specific users + * @param {string[]} [params.userId] Optional array of uuids representing the user subject + * @param {string[]} [params.identityId] Optionalarray of uuids representing the user identity + * @param {string[]} [params.idp] Optional array of identity providers + * @param {string} [params.username] Optional username string to match on + * @param {string} [params.email] Optional email string to match on + * @param {string} [params.firstName] Optional firstName string to match on + * @param {string} [params.fullName] Optional fullName string to match on + * @param {string} [params.lastName] Optional lastName string to match on + * @param {boolean} [params.active] Optional boolean on user active status + * @returns {Promise} The result of running the find operation + */ + searchUsers: (params: UserSearchParameters) => { + return prisma.user.findMany({ + where: { + userId: { in: params.userId }, + identityId: { in: params.identityId }, + idp: { in: params.idp }, + username: { contains: params.username }, + email: { contains: params.email }, + firstName: { contains: params.firstName }, + fullName: { contains: params.fullName }, + lastName: { contains: params.lastName }, + active: params.active + } + }); + }, + + /** + * @function updateUser + * Updates a user record only if there are changed values + * @param {string} userId The userId uuid + * @param {object} data Incoming user data + * @param {object} [etrx=undefined] An optional Prisma Transaction object + * @returns {Promise} The result of running the patch operation + * @throws The error encountered upon db transaction failure + */ + updateUser: async (userId: string, data: User, etrx: Prisma.TransactionClient | undefined = undefined) => { + // Check if any user values have changed + const oldUser = await service.readUser(userId); + const diff = Object.entries(data).some(([key, value]) => oldUser && oldUser[key as keyof User] !== value); + + let response; + + if (diff) { + const _updateUser = async (userId: string, data: User, trx: Prisma.TransactionClient | undefined = undefined) => { + // Patch existing user + if (data.idp) { + const identityProvider = await service.readIdp(data.idp, trx); + if (!identityProvider) await service.createIdp(data.idp, trx); + } + + const obj = { + identityId: data.identityId, + username: data.username, + fullName: data.fullName, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + idp: data.idp, + updatedBy: data.userId + }; + + // TODO: Add support for updating userId primary key in the event it changes + response = await trx?.user.update({ + data: obj, + where: { + userId: userId + } + }); + }; + + // Call with proper transaction + if (etrx) { + await _updateUser(userId, data, etrx); + } else { + await prisma.$transaction(async (trx) => { + await _updateUser(userId, data, trx); + }); + } + + return response; + } else { + // Nothing to update + return oldUser; + } + } +}; + +export default service; diff --git a/app/src/types/User.ts b/app/src/types/User.ts new file mode 100644 index 00000000..3400e1dd --- /dev/null +++ b/app/src/types/User.ts @@ -0,0 +1,13 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type User = { + userId?: string; + identityId: string; + idp?: string; + username: string; + email?: string; + firstName?: string; + fullName?: string; + lastName?: string; + active?: boolean; +} & IStamps; diff --git a/app/src/types/UserSearchParameters.ts b/app/src/types/UserSearchParameters.ts new file mode 100644 index 00000000..a49b61aa --- /dev/null +++ b/app/src/types/UserSearchParameters.ts @@ -0,0 +1,11 @@ +export type UserSearchParameters = { + userId?: string[]; + identityId?: string[]; + idp?: string[]; + username?: string; + email?: string; + firstName?: string; + fullName?: string; + lastName?: string; + active?: boolean; +}; diff --git a/app/src/types/index.ts b/app/src/types/index.ts new file mode 100644 index 00000000..f418d34f --- /dev/null +++ b/app/src/types/index.ts @@ -0,0 +1,7 @@ +export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; +export type { ChefsSubmissionForm } from './ChefsSubmissionForm'; +export type { ChefsSubmissionFormExport } from './ChefsSubmissionFormExport'; +export type { CurrentUser } from './CurrentUser'; +export type { User } from './User'; +export type { UserSearchParameters } from './UserSearchParameters'; +export type { YRN } from './YRN'; From 87ff2fb8059bbd6055111f7ffc646cfc8fb03678 Mon Sep 17 00:00:00 2001 From: Kyle Morel Date: Fri, 15 Dec 2023 11:56:56 -0800 Subject: [PATCH 2/8] Update app name --- README.md | 16 ++++++++++++---- SECURITY.md | 8 ++++---- frontend/README.md | 6 +++--- frontend/index.html | 2 +- frontend/src/components/layout/Header.vue | 2 +- frontend/src/router/index.ts | 4 ++-- frontend/src/views/HomeView.vue | 2 +- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 833d3cb6..9d0e1f05 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/77078c9bd93bd99d5840/maintainability)](https://codeclimate.com/github/bcgov/nr-permitconnect-navigator-service/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/77078c9bd93bd99d5840/test_coverage)](https://codeclimate.com/github/bcgov/nr-permitconnect-navigator-service/test_coverage) -A clean Vue 3 frontend & backend scaffold example +NR PermitConnect Navigator Service To learn more about the **Common Services** available visit the [Common Services Showcase](https://bcgov.github.io/common-service-showcase/) page. @@ -20,7 +20,10 @@ app/ - Application Root ├── config/ - configuration exposed as environment variables ├── src/ - Node.js web application │ ├── components/ - Components Layer +│ ├── db/ - Database Layer │ ├── controllers/ - Controller Layer +│ ├── db/ - Database Layer +│ ├── interfaces/ - Typescript interface definitions │ ├── middleware/ - Middleware Layer │ ├── routes/ - Routes Layer │ ├── services/ - Services Layer @@ -39,7 +42,7 @@ frontend/ - Frontend Root │ ├── types/ - Typescript type definitions │ ├── utils/ - Utility components │ └── views/ - View Layer -└── tests/ - Node.js web application tests +└── tests/ - Vitest web application tests CODE-OF-CONDUCT.md - Code of Conduct COMPLIANCE.yaml - BCGov PIA/STRA compliance status CONTRIBUTING.md - Contributing Guidelines @@ -50,18 +53,23 @@ SECURITY.md - Security Policy and Reporting ## Documentation -- [Application Readme](frontend/README.md) +- [Application Readme](app/README.md) +- [Frontend Readme](frontend/README.md) - [Product Roadmap](https://github.com/bcgov/nr-permitconnect-navigator-service/wiki/Product-Roadmap) - [Product Wiki](https://github.com/bcgov/nr-permitconnect-navigator-service/wiki) - [Security Reporting](SECURITY.md) ## Quick Start Dev Guide -You can quickly run this application in development mode after cloning by opening two terminal windows and running the following commands (assuming you have already set up local configuration as well). Refer to the [Application Readme](app/README.md) and [Frontend Readme](app/frontend/README.md) for more details. +You can quickly run this application in development mode after cloning by opening two terminal windows and running the following commands (assuming you have already set up local configuration as well). Refer to the [Application Readme](app/README.md) and [Frontend Readme](/frontend/README.md) for more details. + +- Create `.env` in the root directory with the following + - `DATABASE_URL="your_connection_string"` ``` cd app npm i +npm run prisma:migrate npm run serve ``` diff --git a/SECURITY.md b/SECURITY.md index 6e15f72d..b5918469 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policies and Procedures -This document outlines security procedures and general policies for the NR Permitting Navigator Service +This document outlines security procedures and general policies for the NR PermitConnect Navigator Service project. - [Supported Versions](#supported-versions) @@ -10,7 +10,7 @@ project. ## Supported Versions -At this time, only the latest version of NR Permitting Navigator Service is supported. +At this time, only the latest version of NR PermitConnect Navigator Service is supported. | Version | Supported | | ------- | ------------------ | @@ -19,8 +19,8 @@ At this time, only the latest version of NR Permitting Navigator Service is supp ## Reporting a Bug -The `CSS` team and community take all security bugs in `NR Permitting Navigator Service` seriously. -Thank you for improving the security of `NR Permitting Navigator Service`. We appreciate your efforts and +The `CSS` team and community take all security bugs in `NR PermitConnect Navigator Service` seriously. +Thank you for improving the security of `NR PermitConnect Navigator Service`. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. diff --git a/frontend/README.md b/frontend/README.md index 3a7949ef..00e3eb29 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ -# app +# NR PermitConnect Navigator Service Frontend -This template should help get you started developing with Vue 3 in Vite. +This template should help get you started developing with NR PermitConnect Navigator Service ## Recommended IDE Setup @@ -30,7 +30,7 @@ npm install ### Compile and Hot-Reload for Development ```sh -npm run dev +npm run serve ``` ### Type-Check, Compile and Minify for Production diff --git a/frontend/index.html b/frontend/index.html index 2a91d76b..b5601930 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - NR Permitting Navigator Service + NR PermitConnect Navigator Service diff --git a/frontend/src/components/layout/Header.vue b/frontend/src/components/layout/Header.vue index faa7f9a9..1798a25f 100644 --- a/frontend/src/components/layout/Header.vue +++ b/frontend/src/components/layout/Header.vue @@ -17,7 +17,7 @@ import { LoginButton } from '@/components/layout';
-

NR Permitting Navigator Service

+

NR PermitConnect Navigator Service

diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bff3d363..27747d02 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -128,8 +128,8 @@ export default function getRouter() { router.afterEach((to) => { // Update document title document.title = to.meta.title - ? `NR Permitting Navigator Service - ${to.meta.title}` - : 'NR Permitting Navigator Service'; + ? `NR PermitConnect Navigator Service - ${to.meta.title}` + : 'NR PermitConnect Navigator Service'; appStore.endDeterminateLoading(); }); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 40909d16..665b1985 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -55,7 +55,7 @@ const languagesEcosystem: Array<{ text: string; href: string }> = [
-

Welcome to the Permitting Navigator Service!

+

Welcome to the PermitConnect Navigator Service!

Frontend Ecosystem

Date: Fri, 15 Dec 2023 14:23:17 -0800 Subject: [PATCH 3/8] Update Helmet content security policy --- app/app.ts | 7 +++++-- app/config/custom-environment-variables.json | 5 ----- app/config/default.json | 5 ----- app/src/services/chefs.ts | 18 ------------------ 4 files changed, 5 insertions(+), 30 deletions(-) diff --git a/app/app.ts b/app/app.ts index 412567ba..146111bc 100644 --- a/app/app.ts +++ b/app/app.ts @@ -11,7 +11,7 @@ import querystring from 'querystring'; import { name as appName, version as appVersion } from './package.json'; import { DEFAULTCORS } from './src/components/constants'; import { getLogger, httpLogger } from './src/components/log'; -import { getGitRevision, parseCSV, readIdpList } from './src/components/utils'; +import { getGitRevision, readIdpList } from './src/components/utils'; import v1Router from './src/routes/v1'; import type { Request, Response } from 'express'; @@ -34,7 +34,10 @@ app.use( helmet({ contentSecurityPolicy: { directives: { - 'default-src': parseCSV(config.get('server.helmet.contentSecurityPolicy.defaultSrc')) + 'default-src': [ + "'self'", // eslint-disable-line + new URL(config.get('server.oidc.serverUrl')).origin + ] } } }) diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index 9dee6362..89d14efe 100644 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -24,11 +24,6 @@ } } }, - "helmet": { - "contentSecurityPolicy": { - "defaultSrc": "SERVER_HELMET_CONTENTSECURITYPOLICY_DEFAULTSRC" - } - }, "oidc": { "enabled": "SERVER_OIDC_ENABLED", "clientId": "SERVER_OIDC_CLIENTID", diff --git a/app/config/default.json b/app/config/default.json index c27f3489..a6e010c9 100644 --- a/app/config/default.json +++ b/app/config/default.json @@ -13,11 +13,6 @@ "poolMax": "10", "username": "app" }, - "helmet": { - "contentSecurityPolicy": { - "defaultSrc": "'self'" - } - }, "logLevel": "http", "port": "8080" } diff --git a/app/src/services/chefs.ts b/app/src/services/chefs.ts index d312042c..1de0d331 100644 --- a/app/src/services/chefs.ts +++ b/app/src/services/chefs.ts @@ -93,24 +93,6 @@ const service = { } }, - updateSubmission: async (data: ChefsSubmissionForm) => { - try { - await prisma.submission.update({ - data: { - ...data, - addedToATS: fromYrn(data.addedToATS), - financiallySupported: fromYrn(data.financiallySupported), - updatedAai: fromYrn(data.updatedAai) - }, - where: { - submissionId: data.submissionId - } - }); - } catch (e: unknown) { - throw e; - } - }, - getSubmissionStatus: async (formId: string, formSubmissionId: string) => { try { const response = await chefsAxios(formId).get(`submissions/${formSubmissionId}/status`); From 66dd092a035d63cb5708f8ecc5cadb23b0ba25d2 Mon Sep 17 00:00:00 2001 From: Kyle Morel Date: Fri, 15 Dec 2023 17:12:20 -0800 Subject: [PATCH 4/8] Creating a global Prisma client --- app/app.ts | 2 +- app/src/db/dataConnection.ts | 26 ++++++++++++++++++++++++++ app/src/services/chefs.ts | 4 +--- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 app/src/db/dataConnection.ts diff --git a/app/app.ts b/app/app.ts index 146111bc..3decfda8 100644 --- a/app/app.ts +++ b/app/app.ts @@ -36,7 +36,7 @@ app.use( directives: { 'default-src': [ "'self'", // eslint-disable-line - new URL(config.get('server.oidc.serverUrl')).origin + new URL(config.get('frontend.oidc.authority')).origin ] } } diff --git a/app/src/db/dataConnection.ts b/app/src/db/dataConnection.ts new file mode 100644 index 00000000..6f297304 --- /dev/null +++ b/app/src/db/dataConnection.ts @@ -0,0 +1,26 @@ +import config from 'config'; +import { PrismaClient } from '@prisma/client'; + +let prisma: PrismaClient; + +const db = { + host: config.get('server.db.host'), + user: config.get('server.db.username'), + password: config.get('server.db.password'), + database: config.get('server.db.database'), + port: config.get('server.db.port'), + poolMax: config.get('server.db.poolMax') +}; + +// @ts-expect-error 2458 +if (!prisma) { + const datasourceUrl = `postgresql://${db.user}:${db.password}@${db.host}:${db.port}/${db.database}?&connection_limit=${db.poolMax}`; + prisma = new PrismaClient({ + // TODO: https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging#event-based-logging + log: ['error', 'warn'], + errorFormat: 'pretty', + datasourceUrl: datasourceUrl + }); +} + +export default prisma; diff --git a/app/src/services/chefs.ts b/app/src/services/chefs.ts index 1de0d331..a1e19395 100644 --- a/app/src/services/chefs.ts +++ b/app/src/services/chefs.ts @@ -1,16 +1,14 @@ /* eslint-disable no-useless-catch */ import axios from 'axios'; import config from 'config'; -import { PrismaClient } from '@prisma/client'; import { NIL } from 'uuid'; import { fromYrn, getChefsApiKey, toYrn } from '../components/utils'; +import prisma from '../db/dataConnection'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { ChefsSubmissionForm } from '../types'; -const prisma = new PrismaClient(); - /** * @function chefsAxios * Returns an Axios instance for the CHEFS API From a72ff93245b3ae450799d120ea0f7f2aaa90c830 Mon Sep 17 00:00:00 2001 From: Kyle Morel Date: Tue, 19 Dec 2023 14:49:31 -0800 Subject: [PATCH 5/8] Implement assigning a user to submission Additionally moves the database towards a logical/physical model approach --- app/src/components/utils.ts | 34 +++++++- app/src/controllers/chefs.ts | 6 +- app/src/controllers/index.ts | 1 + app/src/controllers/user.ts | 29 +++++++ app/src/db/migrations/20231212000000_init.ts | 6 +- app/src/db/models/disconnectRelation.ts | 3 + app/src/db/models/identity_provider.ts | 22 +++++ app/src/db/models/index.ts | 4 + app/src/db/models/submission.ts | 52 ++++++++++++ app/src/db/models/user.ts | 30 +++++++ app/src/db/prisma/schema.prisma | 8 +- app/src/routes/v1/index.ts | 4 +- app/src/routes/v1/user.ts | 15 ++++ app/src/services/chefs.ts | 53 +++++------- app/src/services/user.ts | 84 ++++++++++++------- app/src/types/ChefsSubmissionForm.ts | 5 +- app/src/types/IdentityProvider.ts | 6 ++ app/src/types/index.ts | 1 + .../src/components/form/EditableDropdown.vue | 54 ++++++++++++ frontend/src/components/form/index.ts | 1 + .../components/submission/SubmissionForm.vue | 54 +++++++++--- frontend/src/interfaces/IInputEvent.ts | 3 + frontend/src/interfaces/index.ts | 1 + frontend/src/services/index.ts | 1 + frontend/src/services/userService.ts | 35 ++++++++ frontend/src/types/ChefsSubmissionForm.ts | 27 ++++++ frontend/src/types/User.ts | 14 ++++ frontend/src/types/UserSearchParameters.ts | 11 +++ frontend/src/types/YRN.ts | 1 + frontend/src/types/index.ts | 4 + frontend/src/utils/constants.ts | 11 +++ frontend/src/views/SubmissionView.vue | 1 - 32 files changed, 491 insertions(+), 90 deletions(-) create mode 100644 app/src/controllers/user.ts create mode 100644 app/src/db/models/disconnectRelation.ts create mode 100644 app/src/db/models/identity_provider.ts create mode 100644 app/src/db/models/index.ts create mode 100644 app/src/db/models/submission.ts create mode 100644 app/src/db/models/user.ts create mode 100644 app/src/routes/v1/user.ts create mode 100644 app/src/types/IdentityProvider.ts create mode 100644 frontend/src/components/form/EditableDropdown.vue create mode 100644 frontend/src/interfaces/IInputEvent.ts create mode 100644 frontend/src/services/userService.ts create mode 100644 frontend/src/types/ChefsSubmissionForm.ts create mode 100644 frontend/src/types/User.ts create mode 100644 frontend/src/types/UserSearchParameters.ts create mode 100644 frontend/src/types/YRN.ts diff --git a/app/src/components/utils.ts b/app/src/components/utils.ts index a3907d34..8c4fe070 100644 --- a/app/src/components/utils.ts +++ b/app/src/components/utils.ts @@ -3,9 +3,25 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { getLogger } from './log'; -import { ChefsFormConfig, ChefsFormConfigData, YRN } from '../types'; + +import type { ChefsFormConfig, ChefsFormConfigData, YRN } from '../types'; + const log = getLogger(module.filename); +/** + * @function addDashesToUuid + * Yields a lowercase uuid `str` that has dashes inserted, or `str` if not a string. + * @param {string} str The input string uuid + * @returns {string} The string `str` but with dashes inserted, or `str` if not a string. + */ +export function addDashesToUuid(str: string): string { + if (str.length === 32) { + return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice( + 20 + )}`.toLowerCase(); + } else return str; +} + /** * @function fromYrn * Converts a YRN to boolean @@ -76,6 +92,22 @@ export function isTruthy(value: unknown) { return value === true || value === 1 || (isStr && trueStrings.includes(value.toLowerCase())); } +/** + * @function mixedQueryToArray + * Standardizes query params to yield an array of unique string values + * @param {string|string[]} param The query param to process + * @returns {string[]} A unique, non-empty array of string values, or undefined if empty + */ +export function mixedQueryToArray(param: string | Array): Array | undefined { + // Short circuit undefined if param is falsy + if (!param) return undefined; + + const parsed = Array.isArray(param) ? param.flatMap((p) => parseCSV(p)) : parseCSV(param); + const unique = [...new Set(parsed)]; + + return unique.length ? unique : undefined; +} + /** * @function parseCSV * Converts a comma separated value string into an array of string values diff --git a/app/src/controllers/chefs.ts b/app/src/controllers/chefs.ts index 88128e65..6625574b 100644 --- a/app/src/controllers/chefs.ts +++ b/app/src/controllers/chefs.ts @@ -51,8 +51,7 @@ const controller = { getSubmission: async (req: Request, res: Response, next: NextFunction) => { try { - const response = await chefsService.getSubmission(req.query.formId as string, req.params.submissionId); - res.status(200).send(response); + res.status(200).send(await chefsService.getSubmission(req.query.formId as string, req.params.submissionId)); } catch (e: unknown) { next(e); } @@ -60,8 +59,7 @@ const controller = { updateSubmission: async (req: Request, res: Response, next: NextFunction) => { try { - const response = await chefsService.updateSubmission(req.body); - res.status(200).send(response); + res.status(200).send(await chefsService.updateSubmission(req.body)); } catch (e: unknown) { next(e); } diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index e9fba46e..306703e8 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1 +1,2 @@ export { default as chefsController } from './chefs'; +export { default as userController } from './user'; diff --git a/app/src/controllers/user.ts b/app/src/controllers/user.ts new file mode 100644 index 00000000..0ac8cba0 --- /dev/null +++ b/app/src/controllers/user.ts @@ -0,0 +1,29 @@ +import { addDashesToUuid, mixedQueryToArray, isTruthy } from '../components/utils'; +import { userService } from '../services'; + +import type { NextFunction, Request, Response } from 'express'; + +const controller = { + searchUsers: async (req: Request, res: Response, next: NextFunction) => { + try { + const userIds = mixedQueryToArray(req.query.userId as string); + + const response = await userService.searchUsers({ + userId: userIds ? userIds.map((id) => addDashesToUuid(id)) : userIds, + identityId: mixedQueryToArray(req.query.identityId as string), + idp: mixedQueryToArray(req.query.idp as string), + username: req.query.username as string, + email: req.query.email as string, + firstName: req.query.firstName as string, + fullName: req.query.fullName as string, + lastName: req.query.lastName as string, + active: isTruthy(req.query.active as string) + }); + res.status(200).send(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/db/migrations/20231212000000_init.ts b/app/src/db/migrations/20231212000000_init.ts index f946bb71..20ddc91f 100644 --- a/app/src/db/migrations/20231212000000_init.ts +++ b/app/src/db/migrations/20231212000000_init.ts @@ -36,7 +36,7 @@ export async function up(knex: Knex): Promise { knex.schema.createTable('submission', (table) => { table.uuid('submissionId').primary(); table.uuid('assignedToUserId').references('userId').inTable('user').onUpdate('CASCADE').onDelete('CASCADE'); - table.text('confirmationId'); + table.text('confirmationId').notNullable(); table.text('contactEmail'); table.text('contactPhoneNumber'); table.text('contactFirstName'); @@ -53,8 +53,8 @@ export async function up(knex: Knex): Promise { table.text('relatedPermits'); table.boolean('updatedAai'); table.text('waitingOn'); - table.timestamp('submittedAt', { useTz: true }); - table.text('submittedBy'); + table.timestamp('submittedAt', { useTz: true }).notNullable(); + table.text('submittedBy').notNullable(); table.timestamp('bringForwardDate', { useTz: true }); table.text('notes'); stamps(knex, table); diff --git a/app/src/db/models/disconnectRelation.ts b/app/src/db/models/disconnectRelation.ts new file mode 100644 index 00000000..8db1d482 --- /dev/null +++ b/app/src/db/models/disconnectRelation.ts @@ -0,0 +1,3 @@ +export default { + disconnect: true +}; diff --git a/app/src/db/models/identity_provider.ts b/app/src/db/models/identity_provider.ts new file mode 100644 index 00000000..54f6ded6 --- /dev/null +++ b/app/src/db/models/identity_provider.ts @@ -0,0 +1,22 @@ +import { Prisma } from '@prisma/client'; + +import type { IdentityProvider } from '../../types'; + +// Define a type +const _identityProvider = Prisma.validator()({}); +type identity_provider = Prisma.identity_providerGetPayload; + +export default { + toPhysicalModel(input: IdentityProvider) { + return { + ...input + }; + }, + + toLogicalModel(input: identity_provider): IdentityProvider { + return { + idp: input.idp, + active: input.active + }; + } +}; diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts new file mode 100644 index 00000000..1ddde066 --- /dev/null +++ b/app/src/db/models/index.ts @@ -0,0 +1,4 @@ +export { default as disconnectRelation } from './disconnectRelation'; +export { default as identity_provider } from './identity_provider'; +export { default as submission } from './submission'; +export { default as user } from './user'; diff --git a/app/src/db/models/submission.ts b/app/src/db/models/submission.ts new file mode 100644 index 00000000..725cb459 --- /dev/null +++ b/app/src/db/models/submission.ts @@ -0,0 +1,52 @@ +import { Prisma } from '@prisma/client'; + +import { disconnectRelation, user } from '.'; +import { fromYrn, toYrn } from '../../components/utils'; + +import type { ChefsSubmissionForm } from '../../types'; + +// Define a type that includes the relation to 'user' +const _submission = Prisma.validator()({ + include: { user: true } +}); +type submission = Prisma.submissionGetPayload; + +export default { + toPhysicalModel(input: ChefsSubmissionForm) { + return { + ...input, + user: input.user?.userId ? { connect: { userId: input.user.userId } } : disconnectRelation, + addedToATS: fromYrn(input.addedToATS), + financiallySupported: fromYrn(input.financiallySupported), + updatedAai: fromYrn(input.updatedAai) + }; + }, + + toLogicalModel(input: submission): ChefsSubmissionForm { + return { + submissionId: input.submissionId, + confirmationId: input.confirmationId as string, + contactEmail: input.contactEmail ?? undefined, + contactPhoneNumber: input.contactPhoneNumber ?? undefined, + contactFirstName: input.contactFirstName ?? undefined, + contactLastName: input.contactLastName ?? undefined, + intakeStatus: input.intakeStatus ?? undefined, + projectName: input.projectName ?? undefined, + queuePriority: input.queuePriority ?? undefined, + singleFamilyUnits: input.singleFamilyUnits ?? undefined, + streetAddress: input.streetAddress ?? undefined, + atsClientNumber: input.atsClientNumber ?? undefined, + addedToATS: toYrn(input.addedToATS), + financiallySupported: toYrn(input.financiallySupported), + applicationStatus: input.applicationStatus ?? undefined, + relatedPermits: input.relatedPermits ?? undefined, + updatedAai: toYrn(input.updatedAai), + waitingOn: input.waitingOn ?? undefined, + submittedAt: input.submittedAt.toISOString(), + submittedBy: input.submittedBy, + bringForwardDate: input.bringForwardDate?.toISOString() ?? undefined, + notes: input.notes ?? undefined, + user: input.user ? user.toLogicalModel(input.user) : undefined + }; + } +}; diff --git a/app/src/db/models/user.ts b/app/src/db/models/user.ts new file mode 100644 index 00000000..bcf73c09 --- /dev/null +++ b/app/src/db/models/user.ts @@ -0,0 +1,30 @@ +import { Prisma } from '@prisma/client'; + +import type { User } from '../../types'; + +// Define a type +const _user = Prisma.validator()({}); +type user = Prisma.userGetPayload; + +export default { + toPhysicalModel(input: User) { + return { + userId: input.userId as string, + ...input + }; + }, + + toLogicalModel(input: user): User { + return { + userId: input.userId, + identityId: input.userId, + idp: input.idp ?? undefined, + username: input.username, + email: input.email ?? undefined, + firstName: input.firstName ?? undefined, + fullName: input.fullName ?? undefined, + lastName: input.lastName ?? undefined, + active: input.active + }; + } +}; diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index 9a703580..738b3ddb 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -32,14 +32,14 @@ model knex_migrations_lock { model submission { submissionId String @id @db.Uuid assignedToUserId String? @db.Uuid - confirmationId String? + confirmationId String contactEmail String? contactPhoneNumber String? contactFirstName String? contactLastName String? intakeStatus String? projectName String? - queuePriority String? + queuePriority Int? singleFamilyUnits String? streetAddress String? atsClientNumber String? @@ -49,8 +49,8 @@ model submission { relatedPermits String? updatedAai Boolean? waitingOn String? - submittedAt DateTime? @db.Timestamptz(6) - submittedBy String? + submittedAt DateTime @db.Timestamptz(6) + submittedBy String bringForwardDate DateTime? @db.Timestamptz(6) notes String? createdBy String? @default("00000000-0000-0000-0000-000000000000") diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index a9089a8d..cac356e3 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -1,6 +1,7 @@ import { currentUser } from '../../middleware/authentication'; import express from 'express'; import chefs from './chefs'; +import user from './user'; const router = express.Router(); router.use(currentUser); @@ -8,11 +9,12 @@ router.use(currentUser); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/chefs'] + endpoints: ['/chefs', '/user'] }); }); /** CHEFS Router */ router.use('/chefs', chefs); +router.use('/user', user); export default router; diff --git a/app/src/routes/v1/user.ts b/app/src/routes/v1/user.ts new file mode 100644 index 00000000..8dff378d --- /dev/null +++ b/app/src/routes/v1/user.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { userController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; + +import type { NextFunction, Request, Response } from 'express'; + +const router = express.Router(); +router.use(requireSomeAuth); + +// Submission endpoint +router.get('/', (req: Request, res: Response, next: NextFunction): void => { + userController.searchUsers(req, res, next); +}); + +export default router; diff --git a/app/src/services/chefs.ts b/app/src/services/chefs.ts index a1e19395..0af2e93a 100644 --- a/app/src/services/chefs.ts +++ b/app/src/services/chefs.ts @@ -1,10 +1,10 @@ /* eslint-disable no-useless-catch */ import axios from 'axios'; import config from 'config'; -import { NIL } from 'uuid'; -import { fromYrn, getChefsApiKey, toYrn } from '../components/utils'; +import { getChefsApiKey } from '../components/utils'; import prisma from '../db/dataConnection'; +import { submission } from '../db/models'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { ChefsSubmissionForm } from '../types'; @@ -38,23 +38,21 @@ const service = { getSubmission: async (formId: string, submissionId: string) => { try { - // Try to pull data from our DB - let result = await prisma.submission.findUnique({ + // Check if record exists in our db + const count = await prisma.submission.count({ where: { submissionId: submissionId } }); // Pull submission data from CHEFS and store to our DB if it doesn't exist - if (!result) { + if (!count) { const response = (await chefsAxios(formId).get(`submissions/${submissionId}`)).data; const status = (await chefsAxios(formId).get(`submissions/${submissionId}/status`)).data; - // TODO: Assigned to correct user - result = await prisma.submission.create({ + await prisma.submission.create({ data: { submissionId: response.submission.id, - assignedToUserId: NIL, //status[0].assignedToUserId, confirmationId: response.submission.confirmationId, contactEmail: response.submission.submission.data.contactEmail, contactPhoneNumber: response.submission.submission.data.contactPhoneNumber, @@ -62,30 +60,25 @@ const service = { contactLastName: response.submission.submission.data.contactLastName, intakeStatus: status[0].code, projectName: response.submission.submission.data.projectName, - queuePriority: response.submission.submission.data.queuePriority, + queuePriority: parseInt(response.submission.submission.data.queuePriority), singleFamilyUnits: response.submission.submission.data.singleFamilyUnits, streetAddress: response.submission.submission.data.streetAddress, - atsClientNumber: null, - addedToATS: null, - financiallySupported: null, - applicationStatus: null, - relatedPermits: null, - updatedAai: null, - waitingOn: null, submittedAt: response.submission.createdAt, - submittedBy: response.submission.createdBy, - bringForwardDate: null, - notes: null + submittedBy: response.submission.createdBy } }); } - return { - ...result, - addedToATS: toYrn(result.addedToATS), - financiallySupported: toYrn(result.financiallySupported), - updatedAai: toYrn(result.updatedAai) - }; + const result = await prisma.submission.findUnique({ + where: { + submissionId: submissionId + }, + include: { + user: true + } + }); + + return result ? submission.toLogicalModel(result) : undefined; } catch (e: unknown) { throw e; } @@ -93,8 +86,7 @@ const service = { getSubmissionStatus: async (formId: string, formSubmissionId: string) => { try { - const response = await chefsAxios(formId).get(`submissions/${formSubmissionId}/status`); - return response.data; + return (await chefsAxios(formId).get(`submissions/${formSubmissionId}/status`)).data; } catch (e: unknown) { throw e; } @@ -103,12 +95,7 @@ const service = { updateSubmission: async (data: ChefsSubmissionForm) => { try { await prisma.submission.update({ - data: { - ...data, - addedToATS: fromYrn(data.addedToATS), - financiallySupported: fromYrn(data.financiallySupported), - updatedAai: fromYrn(data.updatedAai) - }, + data: submission.toPhysicalModel(data), where: { submissionId: data.submissionId } diff --git a/app/src/services/user.ts b/app/src/services/user.ts index 427f2b6e..901ced41 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -1,13 +1,14 @@ import jwt from 'jsonwebtoken'; -import { Prisma, PrismaClient } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { v4, NIL } from 'uuid'; +import prisma from '../db/dataConnection'; +import { identity_provider, user } from '../db/models'; import { parseIdentityKeyClaims } from '../components/utils'; import type { User, UserSearchParameters } from '../types'; -const prisma = new PrismaClient(); -const dbClient = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); +const trxWrapper = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); /** * The User DB Service @@ -47,10 +48,11 @@ const service = { createIdp: async (idp: string, etrx: Prisma.TransactionClient | undefined = undefined) => { const obj = { idp: idp, + active: true, createdBy: NIL }; - const response = dbClient(etrx).identity_provider.create({ data: obj }); + const response = trxWrapper(etrx).identity_provider.create({ data: identity_provider.toPhysicalModel(obj) }); return response; }, @@ -83,18 +85,20 @@ const service = { if (!identityProvider) await service.createIdp(data.idp, trx); } + const newUser = { + userId: v4(), + identityId: data.identityId, + username: data.username, + fullName: data.fullName, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + idp: data.idp, + createdBy: data.userId + }; + response = await trx.user.create({ - data: { - userId: v4(), - identityId: data.identityId, - username: data.username, - fullName: data.fullName, - email: data.email, - firstName: data.firstName, - lastName: data.lastName, - idp: data.idp, - createdBy: data.userId - } + data: user.toPhysicalModel(newUser) }); } }; @@ -181,11 +185,13 @@ const service = { * @throws The error encountered upon db transaction failure */ readIdp: async (code: string, etrx: Prisma.TransactionClient | undefined = undefined) => { - return await dbClient(etrx).identity_provider.findUnique({ + const response = await trxWrapper(etrx).identity_provider.findUnique({ where: { idp: code } }); + + return response ? identity_provider.toLogicalModel(response) : undefined; }, /** @@ -217,20 +223,42 @@ const service = { * @param {boolean} [params.active] Optional boolean on user active status * @returns {Promise} The result of running the find operation */ - searchUsers: (params: UserSearchParameters) => { - return prisma.user.findMany({ + searchUsers: async (params: UserSearchParameters) => { + const response = await prisma.user.findMany({ where: { - userId: { in: params.userId }, - identityId: { in: params.identityId }, - idp: { in: params.idp }, - username: { contains: params.username }, - email: { contains: params.email }, - firstName: { contains: params.firstName }, - fullName: { contains: params.fullName }, - lastName: { contains: params.lastName }, - active: params.active + OR: [ + { + userId: { in: params.userId, mode: 'insensitive' } + }, + { + identityId: { in: params.identityId, mode: 'insensitive' } + }, + { + idp: { in: params.idp, mode: 'insensitive' } + }, + { + username: { contains: params.username, mode: 'insensitive' } + }, + { + email: { contains: params.email, mode: 'insensitive' } + }, + { + firstName: { contains: params.firstName, mode: 'insensitive' } + }, + { + fullName: { contains: params.fullName, mode: 'insensitive' } + }, + { + lastName: { contains: params.lastName, mode: 'insensitive' } + }, + { + active: params.active + } + ] } }); + + return response.map((x) => user.toLogicalModel(x)); }, /** @@ -270,7 +298,7 @@ const service = { // TODO: Add support for updating userId primary key in the event it changes response = await trx?.user.update({ - data: obj, + data: user.toPhysicalModel(obj), where: { userId: userId } diff --git a/app/src/types/ChefsSubmissionForm.ts b/app/src/types/ChefsSubmissionForm.ts index be44c291..61f1ea59 100644 --- a/app/src/types/ChefsSubmissionForm.ts +++ b/app/src/types/ChefsSubmissionForm.ts @@ -1,9 +1,9 @@ +import { User } from './User'; import { YRN } from './YRN'; import { IStamps } from '../interfaces/IStamps'; export type ChefsSubmissionForm = { submissionId: string; - assignedToUserId?: string; confirmationId: string; contactEmail?: string; contactPhoneNumber?: string; @@ -11,7 +11,7 @@ export type ChefsSubmissionForm = { contactLastName?: string; intakeStatus?: string; projectName?: string; - queuePriority?: string; + queuePriority?: number; singleFamilyUnits?: string; streetAddress?: string; atsClientNumber?: string; @@ -25,4 +25,5 @@ export type ChefsSubmissionForm = { submittedBy: string; bringForwardDate?: string; notes?: string; + user?: User; } & IStamps; diff --git a/app/src/types/IdentityProvider.ts b/app/src/types/IdentityProvider.ts new file mode 100644 index 00000000..a899adf3 --- /dev/null +++ b/app/src/types/IdentityProvider.ts @@ -0,0 +1,6 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type IdentityProvider = { + idp: string; + active: boolean; +} & IStamps; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index f418d34f..5a317bab 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -2,6 +2,7 @@ export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionForm } from './ChefsSubmissionForm'; export type { ChefsSubmissionFormExport } from './ChefsSubmissionFormExport'; export type { CurrentUser } from './CurrentUser'; +export type { IdentityProvider } from './IdentityProvider'; export type { User } from './User'; export type { UserSearchParameters } from './UserSearchParameters'; export type { YRN } from './YRN'; diff --git a/frontend/src/components/form/EditableDropdown.vue b/frontend/src/components/form/EditableDropdown.vue new file mode 100644 index 00000000..d82976eb --- /dev/null +++ b/frontend/src/components/form/EditableDropdown.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/components/form/index.ts b/frontend/src/components/form/index.ts index 2bccdc93..f7339d2f 100644 --- a/frontend/src/components/form/index.ts +++ b/frontend/src/components/form/index.ts @@ -1,6 +1,7 @@ export { default as Calendar } from './Calendar.vue'; export { default as CopyToClipboard } from './CopyToClipboard.vue'; export { default as Dropdown } from './Dropdown.vue'; +export { default as EditableDropdown } from './EditableDropdown.vue'; export { default as GridRow } from './GridRow.vue'; export { default as Password } from './Password.vue'; export { default as TextArea } from './TextArea.vue'; diff --git a/frontend/src/components/submission/SubmissionForm.vue b/frontend/src/components/submission/SubmissionForm.vue index fa8ecdda..1c424204 100644 --- a/frontend/src/components/submission/SubmissionForm.vue +++ b/frontend/src/components/submission/SubmissionForm.vue @@ -1,12 +1,18 @@