diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8a4b26ff..bf7fcfd6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,6 +7,7 @@ "andrewleedham.vscode-css-modules", "esbenp.prettier-vscode", "gruntfuggly.todo-tree", + "jock.svg", "msjsdiag.debugger-for-chrome", "orta.vscode-jest", "ryanluker.vscode-coverage-gutters", diff --git a/README.md b/README.md index f351fa5e..27b6cf63 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ section. Everyone contributing to this repo should read this document before doing anything: [IMPORTANT.md](docs/IMPORTANT.md) -For specialized instructions (to save you time trying to do various things): [HOWTO.md](docs/HOWTO.md) +For specialized instructions (to save you time trying to do various things): [DEV_HOWTO.md](docs/DEV_HOWTO.md) Many code standards and conventions can be picked up from existing patterns in the code but it is advisable to use this resource as well: [CODE_STANDARDS.md](docs/CODE_STANDARDS.md) diff --git a/atoll-core-main.code-workspace b/atoll-core-main.code-workspace index 37f386f0..b2b7bb37 100644 --- a/atoll-core-main.code-workspace +++ b/atoll-core-main.code-workspace @@ -26,6 +26,7 @@ "deno.import_intellisense_origins": { "https://deno.land": true }, - "jest.jestCommandLine": "node_modules/.bin/jest" + "jest.jestCommandLine": "node_modules/.bin/jest", + "svg.preview.background": "dark-transparent" } } diff --git a/docs/CODE_STANDARDS.md b/docs/CODE_STANDARDS.md index 54ac4def..52c1ba5a 100644 --- a/docs/CODE_STANDARDS.md +++ b/docs/CODE_STANDARDS.md @@ -56,7 +56,7 @@ Middleware should have `next(action);` as the first line to ensure that state is Ensure that you're using the correct types like this: ``` -export const apiOrchestrationMiddleware = (store: StoreTyped) => (next) => (action: Action) => { +export const apiOrchestrationMiddleware: Middleware<{}, StateTree> = (store: StoreTyped) => (next) => (action: Action) => { ``` State retrieval is very common in middleware so it should be done at the start of the middleware like below. diff --git a/docs/DEV_HOWTO.md b/docs/DEV_HOWTO.md index 33c79927..c88ad514 100644 --- a/docs/DEV_HOWTO.md +++ b/docs/DEV_HOWTO.md @@ -89,3 +89,42 @@ export default connect( mapDispatchToProps )(withTranslation()(App)); ``` + +Generating React Components from SVG Files +========================================== + +This app relies on fast rendering of images so they're all in-lined. This should also make it easier for code-splitting, +lazy-loading, etc. + +Steps to Generate SVG Files (if using Affinity Designer) +-------------------------------------------------------- + +1. The `atoll-shared` repo contains all the SVG assets and generated components. + Use terminal and `cd atoll-shared` to execute all commands below in this folder. +2. The original SVG file should be stored in the `/assets` folder. +3. Make sure to name components using the convention: `{lowercase-component-name}-icon.svg`, + for example `menu-caret-down-icon.svg` +4. Use `npm run gen:react-svg -- menu-caret-down-icon` to generate the basic React component `MenuCareDownIcon.tsx`. +5. The file will be placed in the `/components/atoms/icons` folder. +6. Affinity places additional SVG elements that aren't needed so you should remove them: + - remove the line with `xmlnsSerif=` + - remove the attribute `serifId="{component-title}"` (for example, `serifId="Menu Caret"`) + - change `const classNameToUse = buildClassName(props.className);` + to `const classNameToUse = buildClassName(strokeClass, props.className);` + - add `fill="none"` and `className={classNameToUse}` attributes to the top of the `svg` element. + - remove `id` attribute from the `g` element and replace it with `className={fillClass}`, + for example, ` - - - - - - + + + + + + diff --git a/src/server/api/handlers/backlogItems.ts b/src/server/api/handlers/backlogItems.ts index 3960ac43..a52ae7ff 100644 --- a/src/server/api/handlers/backlogItems.ts +++ b/src/server/api/handlers/backlogItems.ts @@ -194,51 +194,58 @@ export const backlogItemsDeleteHandler = async (req: Request, res: Response) => } }; -const getNewCounterValue = async (projectId: string, backlogItemType: string) => { +/** + * Get a new counter value - caller is responsible for managing the transaction. + */ +const getNewCounterValue = async (projectId: string, backlogItemType: string, transaction: Transaction) => { let result: string; + let stage = "init"; const entitySubtype = backlogItemType === "story" ? "story" : "issue"; - let transaction: Transaction; try { - let rolledBack = false; - transaction = await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }); - let projectSettingsItem: any = await ProjectSettingsDataModel.findOne({ - where: { projectId: projectId }, - transaction - }); + const projectSettingsFindOptions: FindOptions = { + where: { projectId: projectId } + }; + if (transaction) { + projectSettingsFindOptions.transaction = transaction; + } + stage = "retrieve project settings"; + let projectSettingsItem: any = await ProjectSettingsDataModel.findOne(projectSettingsFindOptions); if (!projectSettingsItem) { + stage = "retrieve global project settings"; projectSettingsItem = await ProjectSettingsDataModel.findOne({ where: { projectId: null }, transaction }); } if (projectSettingsItem) { + stage = "process project settings item"; const projectSettingsItemTyped = mapDbToApiProjectSettings(projectSettingsItem); const counterSettings = projectSettingsItemTyped.settings.counters[entitySubtype]; const entityNumberPrefix = counterSettings.prefix; const entityNumberSuffix = counterSettings.suffix; - const counterItem: any = await CounterDataModel.findOne({ - where: { entity: "project", entityId: projectId, entitySubtype }, - transaction - }); + const counterFindOptions: FindOptions = { + where: { entity: "project", entityId: projectId, entitySubtype } + }; + if (transaction) { + counterFindOptions.transaction = transaction; + } + stage = "retrieve counter data for project"; + const counterItem: any = await CounterDataModel.findOne(counterFindOptions); if (counterItem) { + stage = "process counter item"; const counterItemTyped = mapDbToApiCounter(counterItem); counterItemTyped.lastNumber++; let counterValue = entityNumberPrefix || ""; counterValue += formatNumber(counterItemTyped.lastNumber, counterSettings.totalFixedLength); counterValue += entityNumberSuffix || ""; counterItemTyped.lastCounterValue = counterValue; + stage = "store new counter value"; await counterItem.update(counterItemTyped); result = counterItem.lastCounterValue; } } - if (!rolledBack) { - await transaction.commit(); - } } catch (err) { - if (transaction) { - await transaction.rollback(); - } - throw new Error(`Unable to get new ID value, ${err}`); + throw new Error(`Unable to get new ID value (stage "${stage}"), ${err}`); } if (!result) { throw new Error("Unable to get new ID value - could not retrieve counter item"); @@ -247,17 +254,17 @@ const getNewCounterValue = async (projectId: string, backlogItemType: string) => }; export const backlogItemsPostHandler = async (req: Request, res: Response) => { - const bodyWithId = { ...addIdToBody(req.body) }; - if (!bodyWithId.friendlyId) { - const friendlyIdValue = await getNewCounterValue(req.body.projectId, req.body.type); - bodyWithId.friendlyId = friendlyIdValue; - } - const prevBacklogItemId = bodyWithId.prevBacklogItemId; - delete bodyWithId.prevBacklogItemId; let transaction: Transaction; try { let rolledBack = false; transaction = await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }); + const bodyWithId = { ...addIdToBody(req.body) }; + if (!bodyWithId.friendlyId) { + const friendlyIdValue = await getNewCounterValue(req.body.projectId, req.body.type, transaction); + bodyWithId.friendlyId = friendlyIdValue; + } + const prevBacklogItemId = bodyWithId.prevBacklogItemId; + delete bodyWithId.prevBacklogItemId; await sequelize.query('SET CONSTRAINTS "productbacklogitem_backlogitemId_fkey" DEFERRED;', { transaction }); await sequelize.query('SET CONSTRAINTS "productbacklogitem_nextbacklogitemId_fkey" DEFERRED;', { transaction }); const updateBacklogItemPartResult = getUpdatedBacklogItemWhenStatusChanges(null, bodyWithId); diff --git a/src/server/api/handlers/deleters/productBacklogItemDeleter.ts b/src/server/api/handlers/deleters/productBacklogItemDeleter.ts index 01d16c33..5d2edef0 100644 --- a/src/server/api/handlers/deleters/productBacklogItemDeleter.ts +++ b/src/server/api/handlers/deleters/productBacklogItemDeleter.ts @@ -11,6 +11,9 @@ import { buildResponseFromCatchError, buildResponseWithItem } from "../../utils/ import { mapDbToApiProductBacklogItem } from "../../../dataaccess/mappers/dataAccessToApiMappers"; export const removeFromProductBacklog = async (backlogitemId: string, transaction?: Transaction) => { + if (!backlogitemId) { + throw new Error("Unable to remove product backlog item entries using a null/undefined ID"); + } try { const findItemOptions: FindOptions = buildOptionsWithTransaction({ where: { backlogitemId } }, transaction); const item = await ProductBacklogItemDataModel.findOne(findItemOptions); diff --git a/src/server/api/handlers/fetchers/backlogItemFetcher.ts b/src/server/api/handlers/fetchers/backlogItemFetcher.ts index 356956c6..55f0ea08 100644 --- a/src/server/api/handlers/fetchers/backlogItemFetcher.ts +++ b/src/server/api/handlers/fetchers/backlogItemFetcher.ts @@ -105,7 +105,10 @@ export const fetchBacklogItemsByDisplayId = async ( } }; -export const fetchBacklogItems = async (projectId: string | null): Promise => { +export const fetchBacklogItems = async (projectId: string): Promise => { + if (!projectId) { + throw new Error("Unable to retrieve backlog items without specifying a projectId"); + } try { const params = { projectId }; const options = buildOptionsFromParams(params); diff --git a/src/server/api/handlers/inserters/productBacklogItemInserter.ts b/src/server/api/handlers/inserters/productBacklogItemInserter.ts index 816a2af2..4a3ced9c 100644 --- a/src/server/api/handlers/inserters/productBacklogItemInserter.ts +++ b/src/server/api/handlers/inserters/productBacklogItemInserter.ts @@ -17,10 +17,14 @@ export const productBacklogItemFirstItemInserter = async ( bodyWithId, transaction: Transaction ): Promise => { + const projectId = bodyWithId.projectId; + if (!projectId) { + throw new Error("Unable to update the product backlog without a projectId"); + } // inserting first item means one of 2 scenarios: // 1) no items in database yet (add prev = null, next = this new item + add prev = new item, next = null) // 2) insert before first item (update item's prev to this item, add prev = null, next = this new item) - const firstItems = await ProductBacklogItemDataModel.findAll({ where: { backlogitemId: null }, transaction }); + const firstItems = await ProductBacklogItemDataModel.findAll({ where: { backlogitemId: null, projectId }, transaction }); if (!firstItems.length) { // scenario 1, insert head and tail await ProductBacklogItemDataModel.create( diff --git a/src/server/api/handlers/sprints.ts b/src/server/api/handlers/sprints.ts index 998381e0..358b576b 100644 --- a/src/server/api/handlers/sprints.ts +++ b/src/server/api/handlers/sprints.ts @@ -4,15 +4,7 @@ import { StatusCodes } from "http-status-codes"; import { Op } from "sequelize"; // libraries -import { - ApiSprint, - ApiSprintBacklogItem, - DateOnly, - determineSprintStatus, - isoDateStringToDate, - logger, - SprintStatus -} from "@atoll/shared"; +import { ApiSprint, DateOnly, determineSprintStatus, logger, SprintStatus } from "@atoll/shared"; // data access import { sequelize } from "../../dataaccess/connection"; @@ -95,14 +87,15 @@ export const sprintPatchHandler = async (req: Request, res: Response) => { if (!sprint) { respondWithNotFound(res, `Unable to find sprint to patch with ID ${queryParamItemId}`); } else { - const originalSprint = mapDbToApiSprint(sprint); - const invalidPatchMessage = getInvalidPatchMessage(originalSprint, body); + const invalidPatchMessage = getInvalidPatchMessage(sprint, body); if (invalidPatchMessage) { respondWithFailedValidation(res, `Unable to patch: ${invalidPatchMessage}`); } else { - const newItem = getPatchedItem(originalSprint, body); - await sprint.update(mapApiToDbSprint(newItem)); - respondWithItem(res, sprint, originalSprint); + const originalSprint = mapDbToApiSprint(sprint); + const newItem = getPatchedItem(sprint, body); + await sprint.update(newItem); + const apiSprint = mapDbToApiSprint(sprint); + respondWithItem(res, apiSprint, originalSprint); } } } catch (err) { diff --git a/src/server/api/handlers/updaters/userPreferencesUpdater.ts b/src/server/api/handlers/updaters/userPreferencesUpdater.ts new file mode 100644 index 00000000..99642985 --- /dev/null +++ b/src/server/api/handlers/updaters/userPreferencesUpdater.ts @@ -0,0 +1,58 @@ +// libraries +import { ApiUserSettings } from "@atoll/shared"; + +// data access +import { UserSettingsDataModel } from "../../../dataaccess/models/UserSettingsDataModel"; + +// consts/enums +import { + buildNotImplementedResponse, + buildResponseFromCatchError, + RestApiErrorResult, + RestApiItemResult +} from "../../utils/responseBuilder"; + +// interfaces/types +import { HandlerContext } from "../utils/handlerContext"; + +// utils +import { mapDbToApiUserSettings } from "../../../dataaccess/mappers/dataAccessToApiMappers"; +import { getPatchedItem } from "../../utils/patcher"; + +export type UserPreferencesResult = RestApiErrorResult | UserPreferencesItemResult; + +export type UserPreferencesItemResult = RestApiItemResult; + +export const patchUserPreferences = async ( + handlerContext: HandlerContext, + reqBody: any, + originalUserSettings: ApiUserSettings, + dbUserSettings: UserSettingsDataModel, + userId: string +) => { + try { + let newDataItem: ApiUserSettings; + // TODO: Maybe this validation check should occur outside the "patcher"?? + if (userId !== "--self--") { + return buildNotImplementedResponse( + "This endpoint is intended as an admin endpoint, so a typical user would not be able to use it." + ); + } else { + if (reqBody.settings) { + newDataItem = getPatchedItem( + originalUserSettings, + { settings: reqBody.settings }, + { preserveNestedFields: true, allowExtraFields: true } + ); + const transaction = handlerContext.transactionContext.transaction; + await dbUserSettings.update(newDataItem, { + transaction + }); + return mapDbToApiUserSettings(dbUserSettings); + } + return originalUserSettings; + } + } catch (error) { + return buildResponseFromCatchError(error); + } +}; diff --git a/src/server/api/handlers/userPreferences.ts b/src/server/api/handlers/userPreferences.ts index 6ace3cef..17cc4c6d 100644 --- a/src/server/api/handlers/userPreferences.ts +++ b/src/server/api/handlers/userPreferences.ts @@ -7,13 +7,25 @@ import * as findPackageJson from "find-package-json"; import { timeNow } from "@atoll/shared"; // interfaces/types -import type { RestApiErrorResult } from "../utils/responseBuilder"; +import { buildNotFoundResponse, buildResponseWithItem, RestApiErrorResult } from "../utils/responseBuilder"; // utils import { getLoggedInAppUserId } from "../utils/authUtils"; import { getUserPreferences } from "./fetchers/userPreferencesFetcher"; +import { mapDbToApiUserSettings } from "../../dataaccess/mappers/dataAccessToApiMappers"; +import { UserSettingsDataModel } from "../../dataaccess/models/UserSettingsDataModel"; +import { patchUserPreferences } from "./updaters/userPreferencesUpdater"; +import { + beginSerializableTransaction, + finish, + handleFailedValidationResponse, + handleSuccessResponse, + handleUnexpectedErrorResponse, + start +} from "./utils/handlerContext"; +import { respondWithObj } from "api/utils/responder"; -export const userPreferencesHandler = async function (req: Request, res: Response) { +export const userPreferencesGetHandler = async function (req: Request, res: Response) { const packageJson = findPackageJson(__dirname); const packageJsonContents = packageJson.next().value; const version = packageJsonContents.version; @@ -31,3 +43,41 @@ export const userPreferencesHandler = async function (req: Request, res: Respons console.log(`Unable to fetch user preferences: ${errorResult.message}`); } }; + +export const userPreferencesPatchHandler = async function (req: Request, res: Response) { + const handlerContext = start("userPreferencesPatchHandler", res); + try { + const userId = req.params.userId || ""; + if (userId !== "--self--") { + await handleFailedValidationResponse( + handlerContext, + "This endpoint is intended as an admin endpoint, so a typical user would not be able to use it." + ); + } else { + await beginSerializableTransaction(handlerContext); + const appuserId = getLoggedInAppUserId(req); + let dbUserSettings: any = await UserSettingsDataModel.findOne({ + where: { appuserId } + }); + if (dbUserSettings) { + const originalApiUserSettings = mapDbToApiUserSettings(dbUserSettings); + const newDataItem = await patchUserPreferences( + handlerContext, + req.body, + originalApiUserSettings, + dbUserSettings, + userId + ); + + const extra = undefined; + const meta = undefined; + await handleSuccessResponse(handlerContext, buildResponseWithItem(newDataItem, extra, meta)); + } else { + return buildNotFoundResponse("Unable to patch user settings- object was not found for this user"); + } + finish(handlerContext); + } + } catch (err) { + await handleUnexpectedErrorResponse(handlerContext, err); + } +}; diff --git a/src/server/api/handlers/utils/handlerContext.ts b/src/server/api/handlers/utils/handlerContext.ts index ec80b4c1..e84c95c7 100644 --- a/src/server/api/handlers/utils/handlerContext.ts +++ b/src/server/api/handlers/utils/handlerContext.ts @@ -129,6 +129,9 @@ export const rollbackWithMessageAndStatus = async (handlerContext: HandlerContex const handleTransactionRollback = async (handlerContext: HandlerContext, logContext: LoggingContext) => { const context = handlerContext.transactionContext; + if (!context) { + return; + } if (context.transaction && !context.rolledBack) { logger.info("rolling back transaction", [handlerContext.functionTag], logContext); try { diff --git a/src/server/api/handlers/views/planViewBff.ts b/src/server/api/handlers/views/planViewBff.ts index e6d73c54..eb029cff 100644 --- a/src/server/api/handlers/views/planViewBff.ts +++ b/src/server/api/handlers/views/planViewBff.ts @@ -31,14 +31,14 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { const userPreferencesResult = await getUserPreferences("--self--", () => getLoggedInAppUserId(req)); const selectedProjectId = (userPreferencesResult as UserPreferencesItemResult).data.item.settings.selectedProject; - const archived = "N"; const [projectResult, backlogItemsResult, sprintsResult] = await Promise.all([ fetchProject(selectedProjectId), fetchBacklogItems(selectedProjectId), - fetchSprints(selectedProjectId, archived) + fetchSprints(selectedProjectId) ]); const sprintsSuccessResult = sprintsResult as RestApiCollectionResult; let sprints = sprintsSuccessResult.data ? sprintsSuccessResult.data?.items : []; + const archivedSprints = sprints.filter((sprint) => sprint.archived === true); let sprintBacklogItemsResult: SprintBacklogItemsResult | RestApiErrorResult; let sprintBacklogItemsStatus = StatusCodes.OK; let sprintBacklogItemsMessage = ""; @@ -76,6 +76,10 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { isRestApiCollectionResult(sprintBacklogItemsResult) && isRestApiItemResult(projectResult) ) { + const projectStats = { + totalSprintCount: sprints.length, + archivedSprintCount: archivedSprints.length + }; res.json( buildResponseWithData({ backlogItems: backlogItemsResult.data?.items, @@ -83,7 +87,8 @@ export const planViewBffGetHandler = async (req: Request, res: Response) => { sprintBacklogItems: sprintBacklogItemsResult?.data?.items || [], userPreferences: (userPreferencesResult as UserPreferencesItemResult).data?.item, expandedSprintId: expandedSprintId ?? null, - project: projectResult.data?.item + project: projectResult.data?.item, + projectStats }) ); } else { diff --git a/src/server/api/routes.ts b/src/server/api/routes.ts index 5b3ecf92..40221846 100644 --- a/src/server/api/routes.ts +++ b/src/server/api/routes.ts @@ -14,7 +14,8 @@ import { SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME, SPRINT_BACKLOG_PARENT_RESOURCE_NAME, SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME, - SPRINT_RESOURCE_NAME + SPRINT_RESOURCE_NAME, + USER_RESOURCE_NAME } from "../resourceNames"; // utils @@ -42,7 +43,7 @@ import { import { productBacklogItemsGetHandler, productBacklogItemGetHandler } from "./handlers/productBacklogItems"; import { featureTogglesHandler } from "./handlers/featureToggles"; import { rootHandler } from "./handlers/root"; -import { userPreferencesHandler } from "./handlers/userPreferences"; +import { userPreferencesGetHandler, userPreferencesPatchHandler } from "./handlers/userPreferences"; import { loginPostHandler, refreshTokenPostHandler } from "./handlers/auth"; import { sprintBacklogItemPartGetHandler, sprintBacklogItemPartsPostHandler } from "./handlers/sprintBacklogItemParts"; import { planViewBffGetHandler } from "./handlers/views/planViewBff"; @@ -65,6 +66,10 @@ import { backlogItemPartGetHandler, backlogItemPartPatchHandler } from "./handle export const router = express.Router(); +// this is a local mapping to make the URLs easier to read +const USERS = USER_RESOURCE_NAME; +const PROJECTS = PROJECT_RESOURCE_NAME; + router.options("/*", (req, res, next) => { res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.set("Access-Control-Allow-Origin", "*"); @@ -73,89 +78,103 @@ router.options("/*", (req, res, next) => { setupNoAuthRoutes(router, "/", { get: rootHandler }); -setupRoutes(router, "/users/:userId/preferences", { get: userPreferencesHandler }); +setupRoutes(router, `/${USERS}/:userId/preferences`, { get: userPreferencesGetHandler, patch: userPreferencesPatchHandler }); -setupRoutes(router, "/users/:userId/feature-toggles", { get: featureTogglesHandler }); +setupRoutes(router, `/${USERS}/:userId/feature-toggles`, { get: featureTogglesHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}`, { +setupRoutes(router, `/${PROJECTS}`, { get: projectsGetHandler, post: projectPostHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}/:projectId`, { +setupRoutes(router, `/${PROJECTS}/:projectId`, { get: projectGetHandler, patch: projectPatchHandler, delete: projectDeleteHandler }); -setupRoutes(router, `/${PROJECT_RESOURCE_NAME}/:projectId/${SPRINT_RESOURCE_NAME}/:sprintId`, { - get: projectSprintGetHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}`, { - get: sprintsGetHandler, - post: sprintPostHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}/:sprintId`, { - get: sprintGetHandler, - put: sprintPutHandler, - patch: sprintPatchHandler, - delete: sprintDeleteHandler -}); - -setupRoutes(router, `/${SPRINT_RESOURCE_NAME}/:sprintId/update-stats`, { - post: sprintUpdateStatsPostHandler -}); +{ + const SPRINTS = SPRINT_RESOURCE_NAME; + + setupRoutes(router, `/${PROJECTS}/:projectId/${SPRINTS}/:sprintId`, { + get: projectSprintGetHandler + }); + + setupRoutes(router, `/${SPRINTS}`, { + get: sprintsGetHandler, + post: sprintPostHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId`, { + get: sprintGetHandler, + put: sprintPutHandler, + patch: sprintPatchHandler, + delete: sprintDeleteHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/update-stats`, { + post: sprintUpdateStatsPostHandler + }); +} + +{ + const SPRINTS = SPRINT_BACKLOG_PARENT_RESOURCE_NAME; + const BACKLOG_ITEMS = SPRINT_BACKLOG_CHILD_RESOURCE_NAME; + const BACKLOG_ITEMS_PARTS = SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME; + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}`, { + get: sprintBacklogItemsGetHandler, + post: sprintBacklogItemPostHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}/:backlogItemId`, { + get: sprintBacklogItemGetHandler, + delete: sprintBacklogItemDeleteHandler + }); + + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS_PARTS}/:backlogItemPartId`, { + get: sprintBacklogItemPartGetHandler + }); -setupRoutes(router, `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}`, { - get: sprintBacklogItemsGetHandler, - post: sprintBacklogItemPostHandler -}); + setupRoutes(router, `/${SPRINTS}/:sprintId/${BACKLOG_ITEMS}/:backlogItemId/${SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME}`, { + post: sprintBacklogItemPartsPostHandler + }); +} -setupRoutes(router, `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}/:backlogItemId`, { - get: sprintBacklogItemGetHandler, - delete: sprintBacklogItemDeleteHandler -}); +{ + const BACKLOG_ITEMS = BACKLOG_ITEM_RESOURCE_NAME; -setupRoutes( - router, - `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_PART_CHILD_RESOURCE_NAME}/:backlogItemPartId`, - { - get: sprintBacklogItemPartGetHandler - } -); + setupRoutes(router, `/${BACKLOG_ITEMS}`, { get: backlogItemsGetHandler, post: backlogItemsPostHandler }); -setupRoutes( - router, - `/${SPRINT_BACKLOG_PARENT_RESOURCE_NAME}/:sprintId/${SPRINT_BACKLOG_CHILD_RESOURCE_NAME}/:backlogItemId/${SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME}`, - { - post: sprintBacklogItemPartsPostHandler - } -); + setupRoutes(router, `/${BACKLOG_ITEMS}/:itemId`, { + get: backlogItemGetHandler, + put: backlogItemPutHandler, + delete: backlogItemsDeleteHandler + }); -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}`, { get: backlogItemsGetHandler, post: backlogItemsPostHandler }); + setupRoutes(router, `/${BACKLOG_ITEMS}/:itemId/join-unallocated-parts`, { + post: backlogItemJoinUnallocatedPartsPostHandler + }); +} -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}/:itemId`, { - get: backlogItemGetHandler, - put: backlogItemPutHandler, - delete: backlogItemsDeleteHandler -}); +{ + const BACKLOG_ITEM_PARTS = BACKLOG_ITEM_PART_RESOURCE_NAME; -setupRoutes(router, `/${BACKLOG_ITEM_RESOURCE_NAME}/:itemId/join-unallocated-parts`, { - post: backlogItemJoinUnallocatedPartsPostHandler -}); + setupRoutes(router, `/${BACKLOG_ITEM_PARTS}/:itemId`, { + get: backlogItemPartGetHandler, + patch: backlogItemPartPatchHandler + }); +} -setupRoutes(router, `/${BACKLOG_ITEM_PART_RESOURCE_NAME}/:itemId`, { - get: backlogItemPartGetHandler, - patch: backlogItemPartPatchHandler -}); +{ + const PRODUCT_BACKLOG_ITEMS = PRODUCT_BACKLOG_ITEM_RESOURCE_NAME; -setupRoutes(router, `/${PRODUCT_BACKLOG_ITEM_RESOURCE_NAME}`, { get: productBacklogItemsGetHandler }); + setupRoutes(router, `/${PRODUCT_BACKLOG_ITEMS}`, { get: productBacklogItemsGetHandler }); -setupRoutes(router, `/${PRODUCT_BACKLOG_ITEM_RESOURCE_NAME}/:itemId`, { - get: productBacklogItemGetHandler -}); + setupRoutes(router, `/${PRODUCT_BACKLOG_ITEMS}/:itemId`, { + get: productBacklogItemGetHandler + }); +} // bff views setupRoutes(router, `/bff/views/plan`, { get: planViewBffGetHandler }); diff --git a/src/server/api/utils/__tests__/patcher.test.ts b/src/server/api/utils/__tests__/patcher.test.ts index 6f85d5ed..4f4b514a 100644 --- a/src/server/api/utils/__tests__/patcher.test.ts +++ b/src/server/api/utils/__tests__/patcher.test.ts @@ -278,5 +278,22 @@ describe("Patcher", () => { missingField: "keep-this" }); }); + it("should preserve nested fields when preserveNestedFields option is set", () => { + // arrange + const obj = { + settings: { selectedProject: "69a9288264964568beb5dd243dc29008", detectBrowserDarkMode: true } + }; + const fields = { + settings: { selectedProject: "8220723fed61402abb8ee5170be741cb" } + }; + + // act + const actual = getPatchedItem(obj, fields, { preserveNestedFields: true }); + + // assert + expect(actual).toStrictEqual({ + settings: { selectedProject: "8220723fed61402abb8ee5170be741cb", detectBrowserDarkMode: true } + }); + }); }); }); diff --git a/src/server/api/utils/patcher.ts b/src/server/api/utils/patcher.ts index 46360fb0..2a1d8b77 100644 --- a/src/server/api/utils/patcher.ts +++ b/src/server/api/utils/patcher.ts @@ -17,7 +17,15 @@ export const validateBaseKeys = (targetNode: any, sourceNode: any): PatchValidat }; }; -export const validatePatchObjects = (targetNode: any, sourceNode: any): PatchValidationResult => { +export type ValidatePatchObjectsOptions = { + allowExtraFields: boolean; +}; + +export const validatePatchObjects = ( + targetNode: any, + sourceNode: any, + options: ValidatePatchObjectsOptions = { allowExtraFields: false } +): PatchValidationResult => { const validationResult = validateBaseKeys(targetNode, sourceNode); const sourceKeys = Object.keys(sourceNode || {}); sourceKeys.forEach((key) => { @@ -33,7 +41,7 @@ export const validatePatchObjects = (targetNode: any, sourceNode: any): PatchVal } }); return { - valid: validationResult.extraFields.length === 0, + valid: validationResult.extraFields.length === 0 || options.allowExtraFields, extraFields: validationResult.extraFields }; }; @@ -53,10 +61,59 @@ export const getInvalidPatchMessage = (obj: any, fields: any) => { return null; }; -export const getPatchedItem = (obj: T, fields: any): T => { - const validationResult = validatePatchObjects(obj, fields); +export type GetPatchedItemOptions = { + preserveNestedFields: boolean; + allowExtraFields: boolean; +}; + +const getPatchedItemInternal = (obj: T, fields: any, basePropertyPath: string, options: GetPatchedItemOptions): T => { + if (options.allowExtraFields && !options.preserveNestedFields) { + throw new Error("getPatchedItem may not work properly when allowExtraFields is used without preserveNestedFields"); + } + const validationResult = validatePatchObjects(obj, fields, { allowExtraFields: options.allowExtraFields }); if (!validationResult.valid) { throw new Error(getValidationFailureMessage(validationResult)); } + if (options.preserveNestedFields) { + const result: any = {}; + const sourceFieldSet = new Set(); + Object.keys(obj).forEach((fieldName) => { + sourceFieldSet.add(fieldName); + const propertyPath = basePropertyPath ? `${basePropertyPath}.${fieldName}` : fieldName; + const sourceFieldValue = obj[fieldName]; + if (!fields.hasOwnProperty(fieldName)) { + result[fieldName] = sourceFieldValue; + } else if (typeof sourceFieldValue === "object") { + const targetFieldObjectValue = fields[fieldName]; + if (targetFieldObjectValue === null) { + result[fieldName] = null; + } else if (typeof targetFieldObjectValue !== "object") { + throw new Error( + `Unable to patch object- nested target property "${propertyPath}"` + + " is not an object, but source property is" + ); + } else { + result[fieldName] = getPatchedItemInternal(sourceFieldValue, targetFieldObjectValue, propertyPath, options); + } + } else { + const targetFieldValue = fields[fieldName]; + result[fieldName] = targetFieldValue; + } + }); + Object.keys(fields).forEach((fieldName) => { + if (!sourceFieldSet.has(fieldName)) { + result[fieldName] = fields[fieldName]; + } + }); + return result; + } return { ...obj, ...fields }; }; + +export const getPatchedItem = ( + obj: T, + fields: any, + options: GetPatchedItemOptions = { preserveNestedFields: false, allowExtraFields: false } +): T => { + return getPatchedItemInternal(obj, fields, "", options); +}; diff --git a/src/server/api/utils/routerHelper.ts b/src/server/api/utils/routerHelper.ts index 1e934fda..c21b021f 100644 --- a/src/server/api/utils/routerHelper.ts +++ b/src/server/api/utils/routerHelper.ts @@ -46,10 +46,10 @@ export const setupRoutes = (router: Router, path: string, handlers: RouteHandler export const setupNotFoundRoutes = (router: Router) => { router.all("*", (req, res, next) => { - res.json({ + res.status(StatusCodes.NOT_FOUND).send({ status: 404, message: `Cannot ${req.method} ${req.originalUrl}` - }).status(StatusCodes.NOT_FOUND); + }); }); }; diff --git a/src/server/resourceNames.ts b/src/server/resourceNames.ts index c49e4314..2281d2d6 100644 --- a/src/server/resourceNames.ts +++ b/src/server/resourceNames.ts @@ -3,6 +3,7 @@ export const BACKLOG_ITEM_PART_RESOURCE_NAME = "backlog-item-parts"; export const PRODUCT_BACKLOG_ITEM_RESOURCE_NAME = "product-backlog-items"; export const BACKLOG_ITEM_RESOURCE_NAME = "backlog-items"; export const PROJECT_RESOURCE_NAME = "projects"; +export const USER_RESOURCE_NAME = "users"; export const SPRINT_BACKLOG_ITEM_PART_RESOURCE_NAME = "parts"; export const SPRINT_RESOURCE_NAME = "sprints";