From d7a6a3367970b5db1d5c4b69c3421db1c7c96d15 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Sun, 22 Mar 2026 14:13:57 -1000 Subject: [PATCH] refactor(src/lib): 3 - convert remaining JS files to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert all 6 remaining .js files in src/lib/ to TypeScript: - constants.js → constants.ts (trivial rename) - is-client.js → is-client.ts (add return type) - request-logging.js → request-logging.ts (add express types) - zip-format.js → zip-format.ts (add ZipRange type, typed params) - timezones.js → timezones.ts (add interface types for contact/campaign) - interaction-step-helpers.js → interaction-step-helpers.ts (add BaseInteractionStep/TreeNode interfaces, remove isModel param since only GraphQL path is used after dead code removal in PR 1a) Add ESLint override for src/lib/**/*.ts enforcing: - @typescript-eslint/no-explicit-any: error - @typescript-eslint/explicit-module-boundary-types: error Update interaction-step-helpers.spec.ts to remove tests for interactionStepForId (deleted in PR 1a) and update getTopMostParent calls to match new single-arg signature. src/lib/ is now 100% TypeScript — zero .js files remain. Co-Authored-By: Claude Opus 4.6 (1M context) --- .eslintrc.js | 8 ++ src/lib/{constants.js => constants.ts} | 0 src/lib/interaction-step-helpers.js | 107 ----------------------- src/lib/interaction-step-helpers.spec.ts | 32 +------ src/lib/interaction-step-helpers.ts | 60 +++++++++++++ src/lib/is-client.js | 5 -- src/lib/is-client.ts | 2 + src/lib/request-logging.js | 25 ------ src/lib/request-logging.ts | 26 ++++++ src/lib/{timezones.js => timezones.ts} | 38 +++++--- src/lib/{zip-format.js => zip-format.ts} | 40 ++++----- 11 files changed, 144 insertions(+), 199 deletions(-) rename src/lib/{constants.js => constants.ts} (100%) delete mode 100644 src/lib/interaction-step-helpers.js create mode 100644 src/lib/interaction-step-helpers.ts delete mode 100644 src/lib/is-client.js create mode 100644 src/lib/is-client.ts delete mode 100644 src/lib/request-logging.js create mode 100644 src/lib/request-logging.ts rename src/lib/{timezones.js => timezones.ts} (58%) rename src/lib/{zip-format.js => zip-format.ts} (63%) diff --git a/.eslintrc.js b/.eslintrc.js index 95ff3a590..27efd357e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -274,6 +274,14 @@ module.exports = { rules: { "prefer-arrow-functions/prefer-arrow-functions": "off" } + }, + { + // Stricter TypeScript rules for modernized src/lib/ + files: ["src/lib/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/explicit-module-boundary-types": "error" + } } ] }; diff --git a/src/lib/constants.js b/src/lib/constants.ts similarity index 100% rename from src/lib/constants.js rename to src/lib/constants.ts diff --git a/src/lib/interaction-step-helpers.js b/src/lib/interaction-step-helpers.js deleted file mode 100644 index 1e8d70f29..000000000 --- a/src/lib/interaction-step-helpers.js +++ /dev/null @@ -1,107 +0,0 @@ -import filter from "lodash/fp/filter"; -import flow from "lodash/fp/flow"; -import fromPairs from "lodash/fp/fromPairs"; -import map from "lodash/fp/map"; -import reverse from "lodash/fp/reverse"; -import sortBy from "lodash/fp/sortBy"; - -import { DateTime } from "./datetime"; - -export const sortByCreatedAt = (is) => { - const asDate = DateTime.fromISO(is.createdAt); - return asDate.isValid ? asDate : null; -}; - -// Sort by newest first -export const sortByNewest = flow(sortBy(sortByCreatedAt), reverse); - -export const findParent = (interactionStep, allInteractionSteps, isModel) => { - let parent = null; - allInteractionSteps.forEach((step) => { - if (isModel) { - if (step.id === interactionStep.parent_interaction_id) { - parent = { - ...step, - answerLink: interactionStep.answer_option - }; - } - } else if (isModel || (step.question && step.question.answerOptions)) { - step.question.answerOptions.forEach((answer) => { - if ( - answer.nextInteractionStep && - answer.nextInteractionStep.id === interactionStep.id - ) { - parent = { - ...step, - answerLink: answer.value - }; - } - }); - } - }); - return parent; -}; - -export const getInteractionPath = ( - interactionStep, - allInteractionSteps, - isModel -) => { - const path = []; - let parent = findParent(interactionStep, allInteractionSteps, isModel); - while (parent !== null) { - path.unshift(parent); - parent = findParent(parent, allInteractionSteps, isModel); - } - return path; -}; - -export const interactionStepForId = (id, interactionSteps) => { - let interactionStep = null; - interactionSteps.forEach((step) => { - if (step.id === id) { - interactionStep = step; - } - }); - return interactionStep; -}; - -export const getChildren = (interactionStep, allInteractionSteps, isModel) => { - const children = []; - allInteractionSteps.forEach((step) => { - const path = getInteractionPath(step, allInteractionSteps, isModel); - path.forEach((pathElement) => { - if (pathElement.id === interactionStep.id) { - children.push(step); - } - }); - }); - return children; -}; - -export const getTopMostParent = (interactionSteps, isModel) => - sortByNewest(interactionSteps).find((step) => - isModel - ? step.parent_interaction_id === null - : step.parentInteractionId === null - ); - -export const makeTree = (interactionSteps, id = null, indexed = null) => { - const indexedById = - indexed || - flow( - map((is) => [is.id, is]), - fromPairs - )(interactionSteps); - - const root = id ? indexedById[id] : getTopMostParent(interactionSteps, false); - - return { - ...root, - interactionSteps: flow( - filter((is) => is.parentInteractionId === root.id), - sortByNewest, - map((c) => makeTree(interactionSteps, c.id, indexedById)) - )(interactionSteps) - }; -}; diff --git a/src/lib/interaction-step-helpers.spec.ts b/src/lib/interaction-step-helpers.spec.ts index 1619d6f9f..5b1487153 100644 --- a/src/lib/interaction-step-helpers.spec.ts +++ b/src/lib/interaction-step-helpers.spec.ts @@ -1,9 +1,5 @@ import type { InteractionStep } from "../api/interaction-step"; -import { - getTopMostParent, - interactionStepForId, - makeTree -} from "./interaction-step-helpers"; +import { getTopMostParent, makeTree } from "./interaction-step-helpers"; const baseEmptyStep: Omit = { questionText: "", @@ -26,41 +22,21 @@ const step = ( ...overrides }); -describe("interactionStepForId", () => { - it("returns the step matching the given id", () => { - const steps = [step("1", null), step("2", "1"), step("3", "1")]; - expect(interactionStepForId("2", steps)).toEqual(steps[1]); - }); - - it("returns null when no step matches", () => { - const steps = [step("1", null)]; - expect(interactionStepForId("999", steps)).toBeNull(); - }); - - // Documents known behavior: uses forEach instead of find, so returns - // the *last* match if there are duplicates. - it("returns the last match when duplicate ids exist", () => { - const first = step("1", null, { questionText: "first" }); - const duplicate = step("1", null, { questionText: "duplicate" }); - expect(interactionStepForId("1", [first, duplicate])).toEqual(duplicate); - }); -}); - describe("getTopMostParent", () => { it("returns the root step (parentInteractionId === null)", () => { const root = step("1", null); const child = step("2", "1"); - expect(getTopMostParent([root, child], false)).toEqual(root); + expect(getTopMostParent([root, child])).toEqual(root); }); it("returns the newest root when multiple roots exist", () => { const olderRoot = step("1", null); const newerRoot = step("2", null); - expect(getTopMostParent([olderRoot, newerRoot], false)).toEqual(newerRoot); + expect(getTopMostParent([olderRoot, newerRoot])).toEqual(newerRoot); }); it("returns undefined for an empty array", () => { - expect(getTopMostParent([], false)).toBeUndefined(); + expect(getTopMostParent([])).toBeUndefined(); }); }); diff --git a/src/lib/interaction-step-helpers.ts b/src/lib/interaction-step-helpers.ts new file mode 100644 index 000000000..9c8cf68b1 --- /dev/null +++ b/src/lib/interaction-step-helpers.ts @@ -0,0 +1,60 @@ +import filter from "lodash/fp/filter"; +import flow from "lodash/fp/flow"; +import fromPairs from "lodash/fp/fromPairs"; +import map from "lodash/fp/map"; +import reverse from "lodash/fp/reverse"; +import sortBy from "lodash/fp/sortBy"; + +import { DateTime } from "./datetime"; + +interface BaseInteractionStep { + id: string; + createdAt: string; + parentInteractionId?: string | null; + [key: string]: unknown; +} + +interface TreeNode extends BaseInteractionStep { + interactionSteps: TreeNode[]; +} + +export const sortByCreatedAt = (is: BaseInteractionStep): DateTime | null => { + const asDate = DateTime.fromISO(is.createdAt); + return asDate.isValid ? asDate : null; +}; + +// Sort by newest first +export const sortByNewest = flow(sortBy(sortByCreatedAt), reverse); + +export const getTopMostParent = ( + interactionSteps: BaseInteractionStep[] +): BaseInteractionStep | undefined => + sortByNewest(interactionSteps).find( + (step: BaseInteractionStep) => step.parentInteractionId === null + ); + +export const makeTree = ( + interactionSteps: BaseInteractionStep[], + id: string | null = null, + indexed: Record | null = null +): TreeNode => { + const indexedById = + indexed || + flow( + map((is: BaseInteractionStep) => [is.id, is]), + fromPairs + )(interactionSteps); + + const root = id ? indexedById[id] : getTopMostParent(interactionSteps); + + return { + ...root, + interactionSteps: flow( + filter((is: BaseInteractionStep) => is.parentInteractionId === root?.id), + sortByNewest, + map((c: BaseInteractionStep) => + makeTree(interactionSteps, c.id, indexedById) + ) + )(interactionSteps) + }; +}; diff --git a/src/lib/is-client.js b/src/lib/is-client.js deleted file mode 100644 index e1a5461b3..000000000 --- a/src/lib/is-client.js +++ /dev/null @@ -1,5 +0,0 @@ -const isClient = () => typeof window !== "undefined"; - -module.exports = { - isClient -}; diff --git a/src/lib/is-client.ts b/src/lib/is-client.ts new file mode 100644 index 000000000..4bada9106 --- /dev/null +++ b/src/lib/is-client.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const isClient = (): boolean => typeof window !== "undefined"; diff --git a/src/lib/request-logging.js b/src/lib/request-logging.js deleted file mode 100644 index 496a58a28..000000000 --- a/src/lib/request-logging.js +++ /dev/null @@ -1,25 +0,0 @@ -import expressWinston from "express-winston"; -import winston from "winston"; - -import { config } from "../config"; - -expressWinston.requestWhitelist.push("body"); - -export default expressWinston.logger({ - transports: [ - config.LOGGING_MONGODB_URI - ? new winston.transports.MongoDB({ - db: config.LOGGING_MONGODB_URI - }) - : new winston.transports.Console() - ], - format: winston.format.combine( - winston.format.colorize(), - winston.format.json() - ), - meta: true, // optional: control whether you want to log the meta data about the request (default to true) - msg: "HTTP {{req.method}} {{req.url}}", // optional: customize the default logging message. E.g. "{{res.statusCode}} {{req.method}} {{res.responseTime}}ms {{req.url}}" - expressFormat: true, // Use the default Express/morgan request formatting. Enabling this will override any msg if true. Will only output colors with colorize set to true - colorize: false, // Color the text and status code, using the Express/morgan color palette (text: gray, status: default green, 3XX cyan, 4XX yellow, 5XX red). - ignoreRoute: (_req, _res) => false // optional: allows to skip some log messages based on request and/or response -}); diff --git a/src/lib/request-logging.ts b/src/lib/request-logging.ts new file mode 100644 index 000000000..f2eb531ce --- /dev/null +++ b/src/lib/request-logging.ts @@ -0,0 +1,26 @@ +import type { Request, Response } from "express"; +import expressWinston from "express-winston"; +import winston from "winston"; + +import { config } from "../config"; + +expressWinston.requestWhitelist.push("body"); + +export default expressWinston.logger({ + transports: [ + config.LOGGING_MONGODB_URI + ? new winston.transports.MongoDB({ + db: config.LOGGING_MONGODB_URI + }) + : new winston.transports.Console() + ], + format: winston.format.combine( + winston.format.colorize(), + winston.format.json() + ), + meta: true, + msg: "HTTP {{req.method}} {{req.url}}", + expressFormat: true, + colorize: false, + ignoreRoute: (_req: Request, _res: Response) => false +}); diff --git a/src/lib/timezones.js b/src/lib/timezones.ts similarity index 58% rename from src/lib/timezones.js rename to src/lib/timezones.ts index 6deda1de3..b78dcdcb7 100644 --- a/src/lib/timezones.js +++ b/src/lib/timezones.ts @@ -1,6 +1,6 @@ import { DateTime, Interval } from "./datetime"; -export const timezones = [ +export const timezones: readonly string[] = [ "US/Alaska", "US/Aleutian", "US/Arizona", @@ -18,28 +18,40 @@ export const timezones = [ ]; /** - * Returns true if it is currently between the start and end hours in the specified timezone. - * - * @param {string} timezone The timezone in which to evaluate - * @param {number} starthour Interval starting hour in 24-hour format - * @param {number} endHour Interval ending hour in 24-hour format + * Returns true if it is currently between the start and end hours + * in the specified timezone. */ -export const isNowBetween = (timezone, starthour, endHour) => { +export const isNowBetween = ( + timezone: string, + startHour: number, + endHour: number +): boolean => { const campaignTime = DateTime.local().setZone(timezone).startOf("day"); return Interval.fromDateTimes( - campaignTime.set({ hour: starthour }), + campaignTime.set({ hour: startHour }), campaignTime.set({ hour: endHour }) ).contains(DateTime.local()); }; +interface ContactWithTimezone { + timezone?: string | null; +} + +interface CampaignWithHours { + timezone: string; + textingHoursStart: number; + textingHoursEnd: number; +} + /** - * Return true if, in the contact's timezone, it is currently within the campaign texting hours. - * - * @param {object} contact GraphQL-type contact - * @param {object} campaign GraphQL-type campaign type + * Return true if, in the contact's timezone, it is currently within + * the campaign texting hours. */ -export const isContactNowWithinCampaignHours = (contact, campaign) => { +export const isContactNowWithinCampaignHours = ( + contact: ContactWithTimezone, + campaign: CampaignWithHours +): boolean => { const timezone = contact.timezone || campaign.timezone; const { textingHoursStart, textingHoursEnd } = campaign; diff --git a/src/lib/zip-format.js b/src/lib/zip-format.ts similarity index 63% rename from src/lib/zip-format.js rename to src/lib/zip-format.ts index 3ea0f7340..5fc76b4b3 100644 --- a/src/lib/zip-format.js +++ b/src/lib/zip-format.ts @@ -1,17 +1,19 @@ -const getFormattedZip = (zip, country = "US") => { +// [firstZip, lastZip, timezoneOffset, hasDst, zipCount] +type ZipRange = readonly [number, number, number, number, number]; + +export const getFormattedZip = (zip: string, country = "US"): string | null => { if (country === "US") { - // Matches 5 digit zip - // eslint-disable-next-line no-useless-escape - const fiveDigitRegex = /(\d{5})([ \-]\d{4})?/; + // Matches 5 digit zip, optionally with a +4 suffix + const fiveDigitRegex = /(\d{5})(?:[ -]\d{4})?/; const [, first5] = zip.match(fiveDigitRegex) || []; if (first5) return first5; - // 4 Digit zips are almost certainly excel treating - // NH, MA, and other 0 initial zips as numbers - // Because of that, we should just 0 pad it + // 4 Digit zips are almost certainly Excel treating + // NH, MA, and other 0-initial zips as numbers. + // Zero-pad to restore the leading 0. const fourDigitRegex = /\d{4}/; - if (zip.match(fourDigitRegex)) { + if (fourDigitRegex.test(zip)) { return `0${zip}`; } @@ -21,7 +23,7 @@ const getFormattedZip = (zip, country = "US") => { throw new Error(`Do not know how to format zip for country: ${country}`); }; -const commonZipRanges = [ +const commonZipRanges: ZipRange[] = [ // list of zip ranges. [, , , , ] [1001, 32401, -5, 1, 31400], [70000, 79821, -6, 1, 9821], @@ -69,18 +71,14 @@ const commonZipRanges = [ commonZipRanges.sort((a, b) => a[0] - b[0]); -const getCommonZipRanges = () => commonZipRanges; +export const getCommonZipRanges = (): readonly ZipRange[] => commonZipRanges; -const zipToTimeZone = (zip) => { - // will search common zip ranges -- won't necessarily find something - // so fallback on looking it up in db - if (typeof zip === "number" || zip.length >= 5) { - zip = parseInt(zip, 10); - return getCommonZipRanges().find((g) => zip >= g[0] && zip < g[1]); +export const zipToTimeZone = (zip: string | number): ZipRange | undefined => { + if (typeof zip === "number" || (typeof zip === "string" && zip.length >= 5)) { + const numericZip = typeof zip === "number" ? zip : parseInt(zip, 10); + return getCommonZipRanges().find( + (g) => numericZip >= g[0] && numericZip < g[1] + ); } -}; - -module.exports = { - getFormattedZip, - zipToTimeZone + return undefined; };