Skip to content

Commit

Permalink
feat: replace all direct env-var reads with ENV object
Browse files Browse the repository at this point in the history
  • Loading branch information
trevor-anderson committed Jun 28, 2023
1 parent 8ff0608 commit aacefe0
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 25 deletions.
3 changes: 2 additions & 1 deletion src/app/apolloClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApolloClient } from "@apollo/client/core";
import pkgJson from "@ROOT/package.json";
import { ENV } from "@app/env";
import { apolloCache } from "./apolloCache";
import { apolloLink } from "./apolloLink";

Expand All @@ -8,5 +9,5 @@ export const apolloClient = new ApolloClient({
...(!!pkgJson?.version && { version: pkgJson.version }),
cache: apolloCache,
link: apolloLink,
connectToDevTools: process.env.NODE_ENV !== "production",
connectToDevTools: ENV.IS_DEV,
});
5 changes: 2 additions & 3 deletions src/app/apolloLink/link.apiHttp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { HttpLink } from "@apollo/client/link/http";
import { ENV } from "@app/env";

export const apiHttpLink = new HttpLink({
uri: `${process.env.REACT_APP_API_PROTOCOL}://${process.env.REACT_APP_API_BASE_URI}/api`,
});
export const apiHttpLink = new HttpLink({ uri: ENV.API_URI });
33 changes: 33 additions & 0 deletions src/app/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Fixit environment variables
*/
export const ENV: FixitEnvVars = {
IS_DEV: import.meta.env.DEV,
IS_PROD: import.meta.env.PROD,
SENTRY_DSN: import.meta.env.VITE_SENTRY_DSN,
API_PROTOCOL: import.meta.env.VITE_API_PROTOCOL,
API_HOST: import.meta.env.VITE_API_HOST,
API_ORIGIN: `${import.meta.env.VITE_API_PROTOCOL}://${import.meta.env.VITE_API_HOST}`,
API_URI: `${import.meta.env.VITE_API_PROTOCOL}://${import.meta.env.VITE_API_HOST}/api`,
STRIPE_PUBLISHABLE_KEY: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY,
PROMO_CODES: JSON.parse(import.meta.env.VITE_FIXIT_SUB_PROMO_CODES_JSON),
};

/**
* Fixit environment variables
*
* Related types: `src/types/Process.env.d.ts`
*/
export type FixitEnvVars = Readonly<{
IS_DEV: boolean;
IS_PROD: boolean;
SENTRY_DSN: string;
API_PROTOCOL: string;
API_HOST: string;
/** `"[API_PROTOCOL]://[API_HOST]"` */
API_ORIGIN: string;
/** `"[API_PROTOCOL]://[API_HOST]/api"` */
API_URI: string;
STRIPE_PUBLISHABLE_KEY: string;
PROMO_CODES: Record<string, number>;
}>;
3 changes: 2 additions & 1 deletion src/components/StripeForm/StripeForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ENV } from "@app/env";
import { StripeFormElements, type StripeFormElementsProps } from "./StripeFormElements";

const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
const stripePromise = loadStripe(ENV.STRIPE_PUBLISHABLE_KEY);

/**
* A form wrapped in the Stripe Elements provider.
Expand Down
8 changes: 3 additions & 5 deletions src/initSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,15 @@ import {
matchRoutes,
} from "react-router-dom";
import * as Sentry from "@sentry/react";
import { ENV } from "@app/env";
import { logger } from "@utils/logger";

Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
dsn: ENV.SENTRY_DSN,
integrations: [
new Sentry.BrowserTracing({
tracePropagationTargets: ["localhost", "staging.gofixit.app", "gofixit.app", /^\//],
/*
Sentry routingInstrumentation docs:
https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/#usage-with-react-router-64-data-api
*/
// Routing integration: React Router v6
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
Expand Down
10 changes: 5 additions & 5 deletions src/pages/CheckoutPage/PromoCodeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { inputBaseClasses } from "@mui/material/InputBase";
import TextField, { textFieldClasses } from "@mui/material/TextField";
import Text from "@mui/material/Typography";
import CheckmarkIcon from "@mui/icons-material/CheckCircle";
import { ENV } from "@app/env";
import { checkoutValuesStore } from "@cache/checkoutValuesStore";
import { checkoutPageClassNames } from "./classNames";
import { PROMO_CODES } from "./promoCodes";

export const PromoCodeInput = () => {
// checkoutValuesStore: only updated upon valid promoCode entry
Expand All @@ -20,7 +20,7 @@ export const PromoCodeInput = () => {
success: false,
});

const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
const handleFocus = () => {
setFieldState({
...fieldState,
touched: true,
Expand All @@ -42,7 +42,7 @@ export const PromoCodeInput = () => {
// If fieldState.value is undefined/empty, do nothing
if (typeof fieldState.value === "string" && fieldState.value.length > 0) {
// If a value is present, check its validity
if (fieldState.value in PROMO_CODES) {
if (fieldState.value in ENV.PROMO_CODES) {
// IF VALID, update checkoutValuesStore
setFieldState({ ...fieldState, success: true });
checkoutValuesStore.set({
Expand All @@ -58,13 +58,13 @@ export const PromoCodeInput = () => {

return (
<StyledDiv className={checkoutPageClassNames.subCostDetails.priceInfoRow}>
{typeof promoCode === "string" && promoCode in PROMO_CODES ? (
{typeof promoCode === "string" && promoCode in ENV.PROMO_CODES ? (
<>
<div>
<CheckmarkIcon color="success" />
<Text className={checkoutPageClassNames.baseText}>Promo code applied</Text>
</div>
<Chip label={`${PROMO_CODES[promoCode]}% off`} color="success" size="small" />
<Chip label={`${ENV.PROMO_CODES[promoCode]}% off`} color="success" size="small" />
</>
) : (
<TextField
Expand Down
6 changes: 3 additions & 3 deletions src/pages/CheckoutPage/SubCostDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { styled } from "@mui/material/styles";
import { chipClasses as muiChipClasses } from "@mui/material/Chip";
import Divider from "@mui/material/Divider";
import Text, { typographyClasses } from "@mui/material/Typography";
import { ENV } from "@app/env";
import { checkoutValuesStore } from "@cache/checkoutValuesStore";
import { formatNum } from "@utils/formatNum";
import { PromoCodeInput } from "./PromoCodeInput";
import { SwitchToAnnual } from "./SwitchToAnnual";
import { checkoutPageClassNames } from "./classNames";
import { PROMO_CODES } from "./promoCodes";
import type { UserSubscriptionPriceLabel } from "@types";

/**
Expand Down Expand Up @@ -141,8 +141,8 @@ export const SUB_DICT_DISPLAY_PARAMS: Record<
*/
export const getTotal_DISPLAY_ONLY = (price: number, promoCode: string | null) => {
return formatNum.toCurrencyStr(
typeof promoCode === "string" && (promoCode ?? "") in PROMO_CODES
? price - price * (PROMO_CODES[promoCode] / 100)
typeof promoCode === "string" && (promoCode ?? "") in ENV.PROMO_CODES
? price - price * (ENV.PROMO_CODES[promoCode] / 100)
: price
);
};
Expand Down
1 change: 0 additions & 1 deletion src/pages/CheckoutPage/promoCodes.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/services/httpService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import axios from "axios";
import { ENV } from "@app/env";
import { logger } from "@utils/logger";
import { storage } from "@utils/storage";

axios.defaults.baseURL = `${process.env.REACT_APP_API_PROTOCOL}://${process.env.REACT_APP_API_BASE_URI}`;
axios.defaults.baseURL = ENV.API_ORIGIN;

// Before each REQUEST goes out, do this
axios.interceptors.request.use(
Expand Down Expand Up @@ -32,7 +33,7 @@ axios.interceptors.response.use(
return Promise.resolve(response.data);
},
(error) => {
logger.error(`ERROR: ${JSON.stringify(error, null, 2)}`, "HTTP_SERVICE");
logger.error(error, "HTTP_SERVICE");

if (error?.response?.status === 401) storage.authToken.remove();
return Promise.reject({
Expand Down
143 changes: 143 additions & 0 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* eslint-disable no-console */
import * as Sentry from "@sentry/react";
import dayjs from "dayjs";
import { ENV } from "@app/env";
import { safeJsonStringify } from "@utils/typeSafety";

/**
* Returns a log-message string.
* - Format: `"[<timestamp>][<label>] <messagePrefix?> <message>"`
* - Timestamp format: `"YYYY:MMM:D k:mm:ss.SSS"`
*/
const getLogMessage = ({
label,
input,
messagePrefix = "",
}: GetLogMessageArgsProvidedByLoggerUtil & GetLogMessageArgsProvidedByHandler): string => {
let message = `[${dayjs().format("YYYY:MMM:D @k:mm:ss.SSS")}][${label}]`;

if (messagePrefix) message += ` ${messagePrefix}`;

message +=
input instanceof Error
? input.message
: typeof input === "string"
? input
: safeJsonStringify(input);

return message;
};

/**
* This function returns a logging function suited for the operating environment:
*
* - IN PRODUCTION:
* - Error logs are always sent to Sentry
* - Non-error logs:
* - Sent to Sentry if `isEnabledInProduction` is `true`
* - Ignored if `isEnabledInProduction` is `false`
*
* - IN NON-PRODUCTION ENVS:
* - Error logs are always logged using `console.error`
*
* > Errors are always logged in all environments regardless of
* `isEnabledInProduction` which only applies to non-error logs.
*/
const getLoggerUtil = ({
label,
isEnabledInProduction = false,
nonProdConsoleMethod = console.log,
}: GetLogMessageArgsProvidedByLoggerUtil & {
/** Bool flag to enable logging non-errors in prod. */
isEnabledInProduction?: boolean;
/** The `console` method to use (default: `console.log`). */
nonProdConsoleMethod?:
| typeof console.log
| typeof console.info
| typeof console.debug
| typeof console.warn
| typeof console.error;
}): LoggerFn => {
// `handleLogMessage` and `handleLogError` are env-dependent and govern where/how logs are sent
const {
handleLogMessage,
handleLogError,
}: Record<"handleLogMessage" | "handleLogError", LoggerFn> =
ENV.IS_PROD === true
? {
handleLogError: (input, messagePrefix) => {
Sentry.captureException(input);
Sentry.captureMessage(getLogMessage({ label, input, messagePrefix }));
},
handleLogMessage:
isEnabledInProduction === true
? (input, messagePrefix) => {
Sentry.captureMessage(getLogMessage({ label, input, messagePrefix }));
}
: () => {},
}
: {
handleLogError: (input, messagePrefix) => {
console.error(getLogMessage({ label, input, messagePrefix }), input);
},
handleLogMessage: (input, messagePrefix) => {
nonProdConsoleMethod(getLogMessage({ label, input, messagePrefix }));
},
};

// The returned fn simply checks if input is an Error, and calls handleLogMessage/handleLogError accordingly
return (input, messagePrefix) => {
if (input instanceof Error) handleLogError(input, messagePrefix);
else handleLogMessage(input, messagePrefix);
};
};

export const logger = {
auth: getLoggerUtil({
label: "AUTH",
}),
stripe: getLoggerUtil({
label: "STRIPE",
}),
debug: getLoggerUtil({
label: "DEBUG",
nonProdConsoleMethod: console.debug,
}),
info: getLoggerUtil({
label: "INFO",
nonProdConsoleMethod: console.info,
}),
error: getLoggerUtil({
label: "ERROR",
nonProdConsoleMethod: console.error,
isEnabledInProduction: true,
}),
gql: getLoggerUtil({
label: "GQL",
}),
gqlError: getLoggerUtil({
label: "GQL-ERROR",
nonProdConsoleMethod: console.error,
isEnabledInProduction: true,
}),
};

/** Args provided to `getLogMessage` by `getLoggerUtil`. */
type GetLogMessageArgsProvidedByLoggerUtil = {
/** A purpose-related label used to differentiate log sources. */
label: string;
};

/** Args provided to `getLogMessage` by `LoggerFn` invocations. */
type GetLogMessageArgsProvidedByHandler = {
/** The raw input provided to a logger function. */
input: unknown;
/** An optional string to prefix the stringified log `input`. */
messagePrefix?: string | undefined;
};

/** This type reflects the structure of the function returned by `getLoggerUtil`. */
type LoggerFn = (
input: GetLogMessageArgsProvidedByHandler["input"],
messagePrefix?: GetLogMessageArgsProvidedByHandler["messagePrefix"]
) => void;
17 changes: 13 additions & 4 deletions src/utils/typeSafety/getTypeSafeErr.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { ENV } from "@app/env";
import { hasKey } from "./hasKey";
import { safeJsonStringify } from "./safeJsonStringify";

/**
* Internal type-safety util which guarantees the returned object is an `Error`.
*/
export const getTypeSafeErr = (
err: unknown,
fallBackErrMsg: string = "An unknown error occurred."
Expand All @@ -10,10 +17,12 @@ export const getTypeSafeErr = (
? new Error(err)
: typeof err === "object" &&
!Array.isArray(err) &&
Object.prototype.hasOwnProperty.call(err, "message")
? new Error((err as { message: string }).message)
hasKey(err, "message") &&
typeof err.message === "string"
? new Error(err.message)
: new Error(
// prettier-ignore
`${fallBackErrMsg} Original error payload: ${typeof err !== "bigint" ? JSON.stringify(err) : "[BigInt]"}`
`${fallBackErrMsg}${
ENV.IS_PROD !== true ? `\nOriginal error payload: ${safeJsonStringify(err)}` : ""
}`
);
};

0 comments on commit aacefe0

Please sign in to comment.