Skip to content

Commit

Permalink
feat(notifications): add notification api and update banner (#986)
Browse files Browse the repository at this point in the history
* setup api

* setup hooks

* hook up banner

* small fix

* resource allowance

* Update api.ts

* arn change

* quick fix

* Update getSystemNotifs.ts

* no toString

* feat: merge hooks into one file

* Create useGetSystemNotifs.test.ts

* fix: infinite re-render

* fix tests
  • Loading branch information
daniel-belcher authored Jan 24, 2025
1 parent a70d67d commit 0db7d48
Show file tree
Hide file tree
Showing 14 changed files with 273 additions and 22 deletions.
6 changes: 6 additions & 0 deletions lib/config/deployment-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ describe("DeploymentConfig", () => {
domainCertificateArn: "domainCertificateArn",
domainName: "domainName",
emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret
notificationSecretName: "notificationSecretName", // pragma: allowlist secret
notificationSecretArn: "notificationSecretArn", // pragma: allowlist secret
googleAnalyticsDisable: "true",
googleAnalyticsGTag: "googleAnalyticsGTag",
idmAuthzApiEndpoint: "idmAuthzApiEndpoint",
Expand Down Expand Up @@ -81,6 +83,8 @@ describe("DeploymentConfig", () => {
domainCertificateArn: "domainCertificateArn",
domainName: "stage-domainName", // Overridden by stage secret
emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret
notificationSecretName: "notificationSecretName", // pragma: allowlist secret
notificationSecretArn: "notificationSecretArn", // pragma: allowlist secret
googleAnalyticsDisable: false, // Converted to boolean and overridden by stage secret
googleAnalyticsGTag: "googleAnalyticsGTag",
idmAuthzApiEndpoint: "idmAuthzApiEndpoint",
Expand Down Expand Up @@ -145,6 +149,8 @@ describe("DeploymentConfig", () => {
domainCertificateArn: "domainCertificateArn",
domainName: "domainName",
emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret
notificationSecretName: "notificationSecretName", // pragma: allowlist secret
notificationSecretArn: "notificationSecretArn", // pragma: allowlist secret
googleAnalyticsDisable: true,
googleAnalyticsGTag: "googleAnalyticsGTag",
idmAuthzApiEndpoint: "idmAuthzApiEndpoint",
Expand Down
4 changes: 4 additions & 0 deletions lib/config/deployment-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type InjectedConfigProperties = {
domainCertificateArn: string;
domainName: string;
emailAddressLookupSecretName: string;
notificationSecretName: string;
notificationSecretArn: string;
googleAnalyticsDisable: boolean;
googleAnalyticsGTag: string;
iamPath: string;
Expand Down Expand Up @@ -117,6 +119,8 @@ export class DeploymentConfig {
typeof config.domainCertificateArn == "string" &&
typeof config.domainName === "string" &&
typeof config.emailAddressLookupSecretName === "string" && // pragma: allowlist secret
typeof config.notificationSecretName === "string" && // pragma: allowlist secret
typeof config.notificationSecretArn === "string" && // pragma: allowlist secret
typeof config.googleAnalyticsDisable == "boolean" &&
typeof config.googleAnalyticsGTag === "string" &&
typeof config.iamPermissionsBoundary === "string" &&
Expand Down
50 changes: 50 additions & 0 deletions lib/lambda/getSystemNotifs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getSystemNotifs } from "./getSystemNotifs";
import * as util from "shared-utils";

vi.mock("shared-utils", () => ({
getExport: vi.fn(),
getSecret: vi.fn(),
}));

describe("notif handler", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("returns 200 and notifs if secret exists", async () => {
vi.stubEnv("notificationSecretArn", "test_secret");
vi.spyOn(util, "getSecret").mockImplementation(async () => "[]");
const result = await getSystemNotifs();
expect(result.statusCode).toBe(200);
expect(result.body).toBe("[]");
});

it("returns 200 and empty array if no notifs", async () => {
vi.stubEnv("notificationSecretArn", "test_secret");
vi.spyOn(util, "getSecret").mockImplementation(async () => null as unknown as string);
const result = await getSystemNotifs();
expect(result.statusCode).toBe(200);
expect(result.body).toBe("[]");
});

it("returns 502 with specific error", async () => {
vi.stubEnv("notificationSecretArn", "error");
vi.spyOn(util, "getSecret").mockImplementation(async () => {
throw new Error("test error");
});
const result = await getSystemNotifs();
expect(result.statusCode).toBe(502);
expect(JSON.parse(result.body).error).toBe("test error");
});

it("returns 502 with generic error", async () => {
vi.stubEnv("notificationSecretArn", undefined);
vi.spyOn(util, "getSecret").mockImplementation(async () => {
throw new Error();
});
const result = await getSystemNotifs();
expect(result.statusCode).toBe(502);
expect(JSON.parse(result.body).error).toBe("Internal server error");
});
});
23 changes: 23 additions & 0 deletions lib/lambda/getSystemNotifs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getSecret } from "shared-utils";
import { response } from "libs/handler-lib";

export const getSystemNotifs = async () => {
try {
const notifs = await getSecret(process.env.notificationSecretArn!);

return response({
statusCode: 200,
body: JSON.parse(notifs) || [],
});
} catch (error: any) {
console.error("Error:", error);
return response({
statusCode: 502,
body: {
error: error.message ? error.message : "Internal server error",
},
});
}
};

export const handler = getSystemNotifs;
1 change: 1 addition & 0 deletions lib/packages/shared-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./guides";
export * from "./inputs";
export * from "./issue";
export * from "./lambda-events";
export * from "./notification";
export * as opensearch from "./opensearch";
export * from "./states";
export * from "./statusHelper";
Expand Down
9 changes: 9 additions & 0 deletions lib/packages/shared-types/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface BannerNotification {
notifId: string;
header: string;
body: string;
buttonText?: string;
buttonLink?: string;
pubDate: string;
expDate?: string;
}
20 changes: 20 additions & 0 deletions lib/stacks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface ApiStackProps extends cdk.NestedStackProps {
brokerString: DeploymentConfigProperties["brokerString"];
dbInfoSecretName: DeploymentConfigProperties["dbInfoSecretName"];
legacyS3AccessRoleArn: DeploymentConfigProperties["legacyS3AccessRoleArn"];
notificationSecretName: DeploymentConfigProperties["notificationSecretName"];
notificationSecretArn: DeploymentConfigProperties["notificationSecretArn"];
}

export class Api extends cdk.NestedStack {
Expand Down Expand Up @@ -55,6 +57,8 @@ export class Api extends cdk.NestedStack {
alertsTopic,
attachmentsBucket,
dbInfoSecretName,
notificationSecretName,
notificationSecretArn,
} = props;

const topicName = `${topicNamespace}aws.onemac.migration.cdc`;
Expand Down Expand Up @@ -113,6 +117,13 @@ export class Api extends cdk.NestedStack {
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${dbInfoSecretName}-*`,
],
}),
new cdk.aws_iam.PolicyStatement({
effect: cdk.aws_iam.Effect.ALLOW,
actions: ["secretsmanager:DescribeSecret", "secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${notificationSecretName}-*`,
],
}),
],
}),
},
Expand Down Expand Up @@ -282,6 +293,14 @@ export class Api extends cdk.NestedStack {
indexNamespace,
},
},
{
id: "getSystemNotifs",
entry: join(__dirname, "../lambda/getSystemNotifs.ts"),
environment: {
notificationSecretName,
notificationSecretArn,
},
},
];

const lambdas = lambdaDefinitions.reduce(
Expand Down Expand Up @@ -439,6 +458,7 @@ export class Api extends cdk.NestedStack {
lambda: lambdas.getAllForms,
method: "GET",
},
getSystemNotifs: { path: "systemNotifs", lambda: lambdas.getSystemNotifs, method: "GET" },
};

const addApiResource = (
Expand Down
2 changes: 2 additions & 0 deletions lib/stacks/parent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export class ParentStack extends cdk.Stack {
openSearchDomainEndpoint: dataStack.openSearchDomainEndpoint,
alertsTopic: alertsStack.topic,
attachmentsBucket: uploadsStack.attachmentsBucket,
notificationSecretName: props.notificationSecretName,
notificationSecretArn: props.notificationSecretArn,
});

const authStack = new Stacks.Auth(this, "auth", {
Expand Down
2 changes: 2 additions & 0 deletions mocks/handlers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { itemHandlers } from "./items";
import { packageActionHandlers } from "./packageActions";
import { searchHandlers } from "./search";
import { submissionHandlers } from "./submissions";
import { notificationHandlers } from "./notifications";
import { typeHandlers } from "./types";

export const apiHandlers = [
...notificationHandlers,
...cpocHandlers,
...itemHandlers,
...packageActionHandlers,
Expand Down
20 changes: 20 additions & 0 deletions mocks/handlers/api/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { http, HttpResponse } from "msw";
import { BannerNotification } from "shared-types";

export type NotifRequestBody = BannerNotification[];

const defaultNotificationHandler = http.get<any, NotifRequestBody>(/\/systemNotifs/, async () => {
return HttpResponse.json(
[
{
notifId: "testId",
body: "testBody",
header: "testHeader",
pubDate: new Date().toISOString(),
},
],
{ status: 200 },
);
});

export const notificationHandlers = [defaultNotificationHandler];
1 change: 1 addition & 0 deletions react-app/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./useGetUser";
export * from "./getAttachmentUrl";
export * from "./useGetPackageActions";
export * from "./useGetCPOCs";
export * from "./useGetSystemNotifs";
export * from "./useGetTypes";
export * from "./amplifyConfig";
export * from "./itemExists";
Expand Down
64 changes: 64 additions & 0 deletions react-app/src/api/useGetSystemNotifs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { beforeAll, afterAll, test, expect, vi, describe } from "vitest";
import { renderHook } from "@testing-library/react";
import { mockUseGetUser } from "mocks";

import * as api from "@/api";
import * as query from "@tanstack/react-query";

import { OneMacUser } from ".";

vi.mock("@/api/useGetUser", () => ({
useGetUser: vi.fn(),
}));

vi.mock("@tanstack/react-query", async (imp) => ({
...(await imp()),
useQuery: vi.fn(),
}));

const testNotifs = [
{
notifId: "testId",
body: "testBody",
header: "testHeader",
pubDate: new Date().toISOString(),
},
];

describe("useGetSystemNotif", () => {
beforeAll(() => {
vi.spyOn(api, "useGetUser").mockImplementation(() => {
const response = mockUseGetUser();
return response as query.UseQueryResult<OneMacUser, unknown>;
});
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => "[]");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => undefined);
vi.spyOn(query, "useQuery").mockImplementation(
() =>
({
data: testNotifs,
}) as query.UseQueryResult<unknown, unknown>,
);
});

afterAll(() => {
vi.resetAllMocks();
});

test("API call", async () => {
const notifs = await api.getSystemNotifs();
expect(notifs).toBeTruthy();
expect(notifs.length).toEqual(1);
});

test("returns test notification array", () => {
// const testHook = api.useGetSystemNotifs();
const {
result: { current: testHook },
} = renderHook(() => api.useGetSystemNotifs());

// expect(testHook.notifications[0].notifId).toBe(testNotifs[0].notifId);
expect(testHook.allNotifications[0].notifId).toBe(testNotifs[0].notifId);
expect(testHook.dismissed).toStrictEqual([]);
});
});
49 changes: 49 additions & 0 deletions react-app/src/api/useGetSystemNotifs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { API } from "aws-amplify";
import { useState, useEffect } from "react";
import { useGetUser } from "@/api";
import { useQuery } from "@tanstack/react-query";
import { BannerNotification, ReactQueryApiError } from "shared-types";

export const getSystemNotifs = async (): Promise<BannerNotification[]> => {
return await API.get("os", "/systemNotifs", {});
};

export const useGetSystemNotifs = () => {
const userQuery = useGetUser();
const [dismissed, setDismissed] = useState<string[]>([]);

useEffect(() => {
const dismissedNotifs = localStorage.getItem(`notifs.${userQuery?.data?.user?.username}`);
const parsed: string[] = JSON.parse(dismissedNotifs) ?? [];
setDismissed(parsed);
}, [userQuery?.data?.user?.username]);

const result = useQuery<BannerNotification[], ReactQueryApiError>(["systemBannerNotifs"], () =>
getSystemNotifs(),
);

const notDismissed = result.data?.filter((i) => !dismissed.includes(i.notifId)) ?? []; //check dismissed
const currentNotifs = notDismissed.filter(
(i) => i.expDate && new Date(i.expDate).getTime() > new Date().getTime(),
); //check expired

const clearNotif = (id?: string) => {
const toBeRemoved = id ?? currentNotifs?.[0]?.notifId ?? "";
const cleared = [...dismissed, toBeRemoved].filter((v, i, a) => a.indexOf(v) === i);

setDismissed(cleared);
localStorage.setItem(`notifs.${userQuery?.data?.user?.username}`, JSON.stringify(cleared));
};

const resetNotifs = () => {
setDismissed([]);
localStorage.setItem(`notifs.${userQuery?.data?.user?.username}`, JSON.stringify([]));
};
return {
notifications: currentNotifs,
dismissed: dismissed,
allNotifications: result.data ?? [],
clearNotif: clearNotif,
resetNotifs: resetNotifs,
};
};
Loading

0 comments on commit 0db7d48

Please sign in to comment.