Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
};
File renamed without changes.
107 changes: 0 additions & 107 deletions src/lib/interaction-step-helpers.js

This file was deleted.

32 changes: 4 additions & 28 deletions src/lib/interaction-step-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -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<InteractionStep, "id" | "parentInteractionId"> = {
questionText: "",
Expand All @@ -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();
});
});

Expand Down
60 changes: 60 additions & 0 deletions src/lib/interaction-step-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, BaseInteractionStep> | 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)
};
};
5 changes: 0 additions & 5 deletions src/lib/is-client.js

This file was deleted.

2 changes: 2 additions & 0 deletions src/lib/is-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const isClient = (): boolean => typeof window !== "undefined";
25 changes: 0 additions & 25 deletions src/lib/request-logging.js

This file was deleted.

26 changes: 26 additions & 0 deletions src/lib/request-logging.ts
Original file line number Diff line number Diff line change
@@ -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
});
38 changes: 25 additions & 13 deletions src/lib/timezones.js → src/lib/timezones.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DateTime, Interval } from "./datetime";

export const timezones = [
export const timezones: readonly string[] = [
"US/Alaska",
"US/Aleutian",
"US/Arizona",
Expand All @@ -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;

Expand Down
Loading
Loading