Просмотр исходного кода

add handler for notification by id (#4280)

Co-authored-by: Feroze Mohideen <feroze@porter.run>
d-g-town 2 лет назад
Родитель
Сommit
8e3a14bab4

+ 3 - 3
api/server/handlers/notifications/get_notification_config.go

@@ -18,7 +18,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 )
 
-// GetNotificationConfigHandler is the handler for the POST /notifications/{notification_config_id} endpoint
+// GetNotificationConfigHandler is the handler for the POST /notifications/config/{notification_config_id} endpoint
 type GetNotificationConfigHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -34,10 +34,10 @@ func NewNotificationConfigHandler(
 	}
 }
 
-// GetNotificationConfigRequest is the request object for the /notifications/{notification_config_id} endpoint
+// GetNotificationConfigRequest is the request object for the /notifications/config/{notification_config_id} endpoint
 type GetNotificationConfigRequest struct{}
 
-// GetNotificationConfigResponse is the response object for the /notifications/{notification_config_id} endpoint
+// GetNotificationConfigResponse is the response object for the /notifications/config/{notification_config_id} endpoint
 type GetNotificationConfigResponse struct {
 	Config Config `json:"config"`
 }

+ 100 - 0
api/server/handlers/notifications/notification.go

@@ -0,0 +1,100 @@
+package notifications
+
+import (
+	"net/http"
+
+	"github.com/google/uuid"
+
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/internal/porter_app/notifications"
+
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+// GetNotificationHandler is the handler for the POST /notifications/{notification_config_id} endpoint
+type GetNotificationHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewNotificationHandler returns a new GetNotificationHandler
+func NewNotificationHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetNotificationHandler {
+	return &GetNotificationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// GetNotificationRequest is the request object for the /notifications/{notification_id} endpoint
+type GetNotificationRequest struct{}
+
+// NotificationResponse is the response object for the notifications endpoint
+type NotificationResponse struct {
+	// Notifications are the notifications associated with the app revision
+	Notification notifications.Notification `json:"notification"`
+}
+
+// ServeHTTP returns a notification by id
+func (n *GetNotificationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-notification")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	notificationID, reqErr := requestutils.GetURLParamString(r, types.URLParamNotificationID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, nil, "error parsing notification id from url")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "notification-id", Value: notificationID},
+	)
+
+	request := &GetNotificationRequest{}
+	if ok := n.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	event, err := n.Repo().PorterAppEvent().NotificationByID(ctx, notificationID)
+	if err != nil {
+		e := telemetry.Error(ctx, span, nil, "error getting notification by id")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	// check project scope indirectly with deployment target
+	deploymentTarget, err := n.Repo().DeploymentTarget().DeploymentTarget(project.ID, event.DeploymentTargetID.String())
+	if err != nil || deploymentTarget == nil || deploymentTarget.ID == uuid.Nil {
+		e := telemetry.Error(ctx, span, err, "notification is not in project scope")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	notification, err := notifications.NotificationFromPorterAppEvent(event)
+	if err != nil {
+		e := telemetry.Error(ctx, span, nil, "error converting app event to notification")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+		return
+	}
+
+	resp := &NotificationResponse{
+		Notification: *notification,
+	}
+
+	n.WriteResult(w, r, resp)
+}

+ 3 - 3
api/server/handlers/notifications/update_notification_config.go

@@ -18,7 +18,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 )
 
-// UpdateNotificationConfigHandler is the handler for the POST /notifications/{notification_config_id} endpoint
+// UpdateNotificationConfigHandler is the handler for the POST /notifications/config/{notification_config_id} endpoint
 type UpdateNotificationConfigHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -34,7 +34,7 @@ func NewUpdateNotificationConfigHandler(
 	}
 }
 
-// UpdateNotificationConfigRequest is the request object for the /notifications/{notification_config_id} endpoint
+// UpdateNotificationConfigRequest is the request object for the /notifications/config/{notification_config_id} endpoint
 type UpdateNotificationConfigRequest struct {
 	Config             Config `json:"config"`
 	SlackIntegrationID uint   `json:"slack_integration_id"`
@@ -57,7 +57,7 @@ type Type struct {
 	Type string `json:"type"`
 }
 
-// UpdateNotificationConfigResponse is the response object for the /notifications/{notification_config_id} endpoint
+// UpdateNotificationConfigResponse is the response object for the /notifications/config/{notification_config_id} endpoint
 type UpdateNotificationConfigResponse struct {
 	ID uint `json:"id"`
 }

+ 32 - 4
api/server/router/notification.go

@@ -58,14 +58,14 @@ func getNotificationRoutes(
 
 	routes := make([]*router.Route, 0)
 
-	// POST /api/projects/{project_id}/notifications/{notification_config_id} -> notifications.NewUpdateNotificationConfigHandler
+	// POST /api/projects/{project_id}/notifications/config/{notification_config_id} -> notifications.NewUpdateNotificationConfigHandler
 	updateNotificationConfigEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamNotificationConfigID),
+				RelativePath: fmt.Sprintf("%s/config/{%s}", relPath, types.URLParamNotificationConfigID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -86,14 +86,14 @@ func getNotificationRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/notifications/{notification_config_id} -> notifications.NewNotificationConfigHandler
+	// GET /api/projects/{project_id}/notifications/config/{notification_config_id} -> notifications.NewNotificationConfigHandler
 	getNotificationConfigEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamNotificationConfigID),
+				RelativePath: fmt.Sprintf("%s/config/{%s}", relPath, types.URLParamNotificationConfigID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -114,5 +114,33 @@ func getNotificationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/notifications/{notification_id} -> notifications.NewNotificationConfigHandler
+	notificationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamNotificationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	notificationHandler := notifications.NewNotificationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: notificationEndpoint,
+		Handler:  notificationHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 0
api/types/request.go

@@ -56,6 +56,7 @@ const (
 	URLParamDatastoreType         URLParam = "datastore_type"
 	URLParamDatastoreName         URLParam = "datastore_name"
 	URLParamNotificationConfigID  URLParam = "notification_config_id"
+	URLParamNotificationID        URLParam = "notification_id"
 	URLParamCloudProviderType     URLParam = "cloud_provider_type"
 	URLParamCloudProviderID       URLParam = "cloud_provider_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"

+ 99 - 13
dashboard/src/lib/porter-apps/notification.ts

@@ -1,11 +1,19 @@
+import { PorterApp } from "@porter-dev/api-contracts";
 import _ from "lodash";
+import { z } from "zod";
 
 import {
   isRevisionNotification,
   isServiceNotification,
+  porterAppNotificationEventMetadataValidator,
   type PorterAppNotification,
 } from "main/home/app-dashboard/app-view/tabs/activity-feed/events/types";
+import { appRevisionValidator } from "lib/revisions/types";
 
+import api from "shared/api";
+import { valueExists } from "shared/util";
+
+import { clientAppFromProto } from ".";
 import {
   ERROR_CODE_APPLICATION_ROLLBACK,
   ERROR_CODE_APPLICATION_ROLLBACK_FAILED,
@@ -16,6 +24,7 @@ type BaseClientNotification = {
   id: string;
   timestamp: string;
   messages: PorterAppNotification[];
+  isHistorical: boolean; // refers to whether the notification currently applies or not
 };
 
 export type ClientServiceNotification = BaseClientNotification & {
@@ -51,20 +60,27 @@ export const isClientRevisionNotification = (
   return notification.scope === "REVISION";
 };
 
-export function deserializeNotifications(
-  notifications: PorterAppNotification[],
-  latestClientServices: ClientService[],
-  latestRevisionId: string
-): ClientNotification[] {
+export function deserializeNotifications({
+  notifications,
+  clientServices,
+  revisionId,
+  isHistorical,
+}: {
+  notifications: PorterAppNotification[];
+  clientServices: ClientService[];
+  revisionId: string;
+  isHistorical: boolean;
+}): ClientNotification[] {
   const revisionNotifications = orderNotificationsByTimestamp(
-    clientRevisionNotifications(notifications),
+    clientRevisionNotifications(notifications, isHistorical),
     "asc"
   );
   const serviceNotifications = orderNotificationsByTimestamp(
     clientServiceNotifications(
       notifications,
-      latestClientServices,
-      latestRevisionId
+      clientServices,
+      revisionId,
+      isHistorical
     ),
     "asc"
   );
@@ -74,11 +90,12 @@ export function deserializeNotifications(
 
 const clientServiceNotifications = (
   notifications: PorterAppNotification[],
-  latestClientServices: ClientService[],
-  latestRevisionId: string
+  clientServices: ClientService[],
+  revisionId: string,
+  isHistorical: boolean
 ): ClientServiceNotification[] => {
   const serviceNotifications = notifications
-    .filter((n) => n.app_revision_id === latestRevisionId)
+    .filter((n) => n.app_revision_id === revisionId)
     .filter(isServiceNotification);
 
   const notificationsGroupedByService = _.groupBy(
@@ -86,7 +103,7 @@ const clientServiceNotifications = (
     (notification) => notification.metadata.service_name
   );
 
-  return latestClientServices
+  return clientServices
     .filter((svc) => notificationsGroupedByService[svc.name.value] != null)
     .map((svc) => {
       const serviceName = svc.name.value;
@@ -112,12 +129,14 @@ const clientServiceNotifications = (
         messages,
         appRevisionId,
         service: svc,
+        isHistorical,
       };
     });
 };
 
 const clientRevisionNotifications = (
-  notifications: PorterAppNotification[]
+  notifications: PorterAppNotification[],
+  isHistorical: boolean
 ): ClientRevisionNotification[] => {
   const revisionNotifications = notifications.filter(isRevisionNotification);
 
@@ -134,6 +153,7 @@ const clientRevisionNotifications = (
       isRollbackRelated:
         notification.error.code === ERROR_CODE_APPLICATION_ROLLBACK ||
         notification.error.code === ERROR_CODE_APPLICATION_ROLLBACK_FAILED,
+      isHistorical,
     };
   });
 };
@@ -152,3 +172,69 @@ const orderNotificationsByTimestamp = <T extends Array<{ timestamp: string }>>(
     }
   });
 };
+
+// TODO: make this generic so that latestrevisioncontext can use the same function
+export const getClientNotificationById = async ({
+  notificationId,
+  projectId,
+  clusterId,
+  appName,
+}: {
+  notificationId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+}): Promise<ClientNotification | undefined> => {
+  try {
+    const res = await api.getNotification(
+      "<token>",
+      {},
+      {
+        project_id: projectId,
+        notification_id: notificationId,
+      }
+    );
+    const { notification: porterAppNotification } = await z
+      .object({
+        notification: porterAppNotificationEventMetadataValidator,
+      })
+      .parseAsync(res.data);
+    const revisionId = porterAppNotification.app_revision_id;
+    const revisionRes = await api.getRevision(
+      "<token>",
+      {},
+      {
+        project_id: projectId,
+        cluster_id: clusterId,
+        porter_app_name: appName,
+        revision_id: revisionId,
+      }
+    );
+    const { app_revision: appRevision } = await z
+      .object({ app_revision: appRevisionValidator })
+      .parseAsync(revisionRes.data);
+
+    const proto = PorterApp.fromJsonString(atob(appRevision.b64_app_proto), {
+      ignoreUnknownFields: true,
+    });
+    const appFromRevision = clientAppFromProto({ proto, overrides: null });
+    const servicesFromRevision = [
+      ...appFromRevision.services,
+      appFromRevision.predeploy?.length
+        ? appFromRevision.predeploy[0]
+        : undefined,
+    ].filter(valueExists);
+
+    const notifications = deserializeNotifications({
+      notifications: [porterAppNotification],
+      clientServices: servicesFromRevision,
+      revisionId,
+      isHistorical: true,
+    });
+
+    if (notifications.length > 1) {
+      return;
+    }
+    return notifications[0];
+  } catch (err) {}
+};

+ 6 - 5
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -320,11 +320,12 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
       return [];
     }
 
-    return deserializeNotifications(
-      latestSerializedNotifications,
-      latestClientServices,
-      latestRevision.id
-    );
+    return deserializeNotifications({
+      notifications: latestSerializedNotifications,
+      clientServices: latestClientServices,
+      revisionId: latestRevision.id,
+      isHistorical: false,
+    });
   }, [latestSerializedNotifications, latestClientServices, latestRevision]);
 
   const loading =

+ 42 - 55
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/NotificationFeed.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
 import { useHistory, useLocation } from "react-router";
 import styled from "styled-components";
 
@@ -10,6 +10,7 @@ import { type DeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import { type ClientNotification } from "lib/porter-apps/notification";
 
 import { useLatestRevision } from "../../LatestRevisionContext";
+import { NotificationContextProvider } from "./expanded-views/NotificationContextProvider";
 import NotificationExpandedView from "./expanded-views/NotificationExpandedView";
 import NotificationList from "./NotificationList";
 
@@ -36,62 +37,37 @@ const NotificationFeed: React.FC<Props> = ({
   const queryParams = new URLSearchParams(search);
   const notificationId = queryParams.get("notification_id");
 
-  const [selectedNotification, setSelectedNotification] = useState<
-    ClientNotification | undefined
-  >(undefined);
-
-  useEffect(() => {
-    if (
-      notificationId &&
-      notifications.length &&
-      notifications.find((n) => n.id === notificationId)
-    ) {
-      setSelectedNotification(
-        notifications.find((n) => n.id === notificationId)
-      );
-    } else {
-      setSelectedNotification(undefined);
-    }
-  }, [notificationId, JSON.stringify(notifications)]);
-
-  if (notifications.length === 0) {
-    return (
-      <Fieldset>
-        <Text size={16}>This application currently has no notifications. </Text>
-        <Spacer height="15px" />
-        <Text color="helper">
-          You will receive notifications here if we need to alert you about any
-          issues with your services.
-        </Text>
-      </Fieldset>
-    );
-  }
-
   return (
     <StyledNotificationFeed>
-      {selectedNotification ? (
-        <>
-          <Link
-            to={tabUrlGenerator({
-              tab: "notifications",
-            })}
-          >
-            <BackButton>
-              <i className="material-icons">keyboard_backspace</i>
-              Notifications
-            </BackButton>
-          </Link>
-          <Spacer y={0.25} />
-          <NotificationExpandedView
-            notification={selectedNotification}
-            projectId={projectId}
-            clusterId={clusterId}
-            appName={appName}
-            deploymentTargetId={deploymentTarget.id}
-            appId={appId}
-          />
-        </>
-      ) : (
+      {notificationId ? (
+        <NotificationContextProvider
+          notificationId={notificationId}
+          projectId={projectId}
+          clusterId={clusterId}
+          appName={appName}
+        >
+          <div>
+            <Link
+              to={tabUrlGenerator({
+                tab: "notifications",
+              })}
+            >
+              <BackButton>
+                <i className="material-icons">keyboard_backspace</i>
+                Notifications
+              </BackButton>
+            </Link>
+            <Spacer y={0.25} />
+            <NotificationExpandedView
+              projectId={projectId}
+              clusterId={clusterId}
+              appName={appName}
+              deploymentTargetId={deploymentTarget.id}
+              appId={appId}
+            />
+          </div>
+        </NotificationContextProvider>
+      ) : notifications.length !== 0 ? (
         <NotificationList
           notifications={notifications}
           onNotificationClick={(notification: ClientNotification) => {
@@ -109,6 +85,17 @@ const NotificationFeed: React.FC<Props> = ({
           appName={appName}
           deploymentTargetId={deploymentTarget.id}
         />
+      ) : (
+        <Fieldset>
+          <Text size={16}>
+            This application currently has no notifications.{" "}
+          </Text>
+          <Spacer height="15px" />
+          <Text color="helper">
+            No issues have been found. You will receive notifications here if we
+            need to alert you about any issues with your services.
+          </Text>
+        </Fieldset>
       )}
     </StyledNotificationFeed>
   );

+ 140 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/NotificationContextProvider.tsx

@@ -0,0 +1,140 @@
+import React, { createContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  getClientNotificationById,
+  type ClientNotification,
+} from "lib/porter-apps/notification";
+
+import notFound from "assets/not-found.png";
+
+import { useLatestRevision } from "../../../LatestRevisionContext";
+
+type NotificationContextType = {
+  notification: ClientNotification;
+};
+export const NotificationContext =
+  createContext<NotificationContextType | null>(null);
+
+export const useNotificationContext = (): NotificationContextType => {
+  const ctx = React.useContext(NotificationContext);
+  if (!ctx) {
+    throw new Error(
+      "useNotificationContext must be used within a NotificationContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type NotificationContextProviderProps = {
+  notificationId: string;
+  children: JSX.Element;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+};
+
+export const NotificationContextProvider: React.FC<
+  NotificationContextProviderProps
+> = ({ notificationId, children, projectId, clusterId, appName }) => {
+  const { latestClientNotifications, tabUrlGenerator, loading } =
+    useLatestRevision(); // this is essentially a cache
+
+  const paramsExist =
+    !!notificationId && !!projectId && !!clusterId && !!appName && !loading;
+  const { data: notification, status } = useQuery(
+    [
+      "getNotification",
+      notificationId,
+      projectId,
+      clusterId,
+      appName,
+      loading,
+      latestClientNotifications,
+    ],
+    async () => {
+      if (!paramsExist) {
+        return;
+      }
+      const latestNotificationMatch = latestClientNotifications.find(
+        (n) => n.id === notificationId
+      );
+      if (latestNotificationMatch) {
+        return latestNotificationMatch;
+      }
+
+      const latestNotificationMatchNested = latestClientNotifications.find(
+        (n) => {
+          return n.messages.find((m) => m.id === notificationId);
+        }
+      );
+
+      if (latestNotificationMatchNested) {
+        return latestNotificationMatchNested;
+      }
+
+      const retrievedNotification = await getClientNotificationById({
+        notificationId,
+        projectId,
+        clusterId,
+        appName,
+      });
+      return retrievedNotification;
+    },
+    {
+      enabled: paramsExist,
+    }
+  );
+
+  if (status === "loading" || !paramsExist) {
+    return <Loading />;
+  }
+
+  if (status === "error" || !notification) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No notification matching id: &quot;{notificationId}&quot; was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link
+          to={tabUrlGenerator({
+            tab: "notifications",
+          })}
+        >
+          Return to notifications
+        </Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <NotificationContext.Provider value={{ notification }}>
+      {children}
+    </NotificationContext.Provider>
+  );
+};
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 59 - 26
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/NotificationExpandedView.tsx

@@ -1,14 +1,15 @@
 import React from "react";
+import AnimateHeight from "react-animate-height";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
-import { type ClientNotification } from "lib/porter-apps/notification";
+import Banner from "components/porter/Banner";
 
+import { useNotificationContext } from "./NotificationContextProvider";
 import RevisionNotificationExpandedView from "./RevisionNotificationExpandedView";
 import ServiceNotificationExpandedView from "./ServiceNotificationExpandedView";
 
 type Props = {
-  notification: ClientNotification;
   projectId: number;
   clusterId: number;
   appName: string;
@@ -17,36 +18,48 @@ type Props = {
 };
 
 const NotificationExpandedView: React.FC<Props> = ({
-  notification,
   projectId,
   clusterId,
   appName,
   deploymentTargetId,
   appId,
 }) => {
-  return match(notification)
-    .with({ scope: "SERVICE" }, (n) => (
-      <ServiceNotificationExpandedView
-        notification={n}
-        projectId={projectId}
-        clusterId={clusterId}
-        appName={appName}
-        deploymentTargetId={deploymentTargetId}
-        appId={appId}
-      />
-    ))
-    .with({ scope: "REVISION" }, (n) => (
-      <RevisionNotificationExpandedView
-        notification={n}
-        projectId={projectId}
-        appName={appName}
-        deploymentTargetId={deploymentTargetId}
-        clusterId={clusterId}
-        appId={appId}
-      />
-    ))
-    .with({ scope: "APPLICATION" }, () => null) // not implemented yet
-    .exhaustive();
+  const { notification } = useNotificationContext();
+
+  return (
+    <div>
+      {notification.isHistorical && (
+        <StyledHistoricalBannerContainer>
+          <AnimateHeight height={"auto"}>
+            <Banner type="warning">This notification has been resolved.</Banner>
+          </AnimateHeight>
+        </StyledHistoricalBannerContainer>
+      )}
+      {match(notification)
+        .with({ scope: "SERVICE" }, (n) => (
+          <ServiceNotificationExpandedView
+            notification={n}
+            projectId={projectId}
+            clusterId={clusterId}
+            appName={appName}
+            deploymentTargetId={deploymentTargetId}
+            appId={appId}
+          />
+        ))
+        .with({ scope: "REVISION" }, (n) => (
+          <RevisionNotificationExpandedView
+            notification={n}
+            projectId={projectId}
+            appName={appName}
+            deploymentTargetId={deploymentTargetId}
+            clusterId={clusterId}
+            appId={appId}
+          />
+        ))
+        .with({ scope: "APPLICATION" }, () => null) // not implemented yet
+        .exhaustive()}
+    </div>
+  );
 };
 
 export default NotificationExpandedView;
@@ -125,3 +138,23 @@ export const StyledMessageFeed = styled.div`
     }
   }
 `;
+
+const StyledHistoricalBannerContainer = styled.div`
+  height: 100%;
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  flex-direction: column;
+  animation: fadeIn 0.3s 0s;
+  padding: 70px;
+  padding-top: 15px;
+  padding-bottom: 15px;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 7 - 5
dashboard/src/main/home/app-dashboard/app-view/tabs/notifications/expanded-views/RevisionNotificationExpandedView.tsx

@@ -79,6 +79,7 @@ const RevisionNotificationExpandedView: React.FC<Props> = ({
 
   const { latestSerializedNotifications } = useLatestRevision();
 
+  // TODO: refactor this so it comes from the notification context provider
   // for rollback notifications, we want to show the notifications for a previous version, which may have different client services
   // therefore, we need to get the client services from that version, and parse the notifications from them
   const rollbackClientServiceNotifications = useMemo(() => {
@@ -109,11 +110,12 @@ const RevisionNotificationExpandedView: React.FC<Props> = ({
       rollbackApp.predeploy?.length ? rollbackApp.predeploy[0] : undefined,
     ].filter(valueExists);
 
-    const rollbackClientNotifications = deserializeNotifications(
-      latestSerializedNotifications,
-      rollbackClientServices,
-      rollbackSourceRevision.id
-    );
+    const rollbackClientNotifications = deserializeNotifications({
+      notifications: latestSerializedNotifications,
+      clientServices: rollbackClientServices,
+      revisionId: rollbackSourceRevision.id,
+      isHistorical: false,
+    });
 
     return (
       rollbackClientNotifications

+ 1 - 1
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -45,7 +45,7 @@ const statusOptions = [
 ];
 
 const typeOptions = [
-  { value: "deployment", icon: deploy, label: "Deploy" },
+  { value: "deploy", icon: deploy, label: "Deploy" },
   { value: "pre-deploy", icon: pre_deploy, label: "Pre-deploy" },
   { value: "build", icon: build, label: "Build" },
 ];

+ 15 - 2
dashboard/src/shared/api.tsx

@@ -680,7 +680,7 @@ const getNotificationConfig = baseApi<
 >("GET", (pathParams) => {
   const { project_id, notification_config_id } = pathParams;
 
-  return `/api/projects/${project_id}/notifications/${notification_config_id}`;
+  return `/api/projects/${project_id}/notifications/config/${notification_config_id}`;
 });
 
 const updateNotificationConfig = baseApi<
@@ -698,7 +698,19 @@ const updateNotificationConfig = baseApi<
 >("POST", (pathParams) => {
   const { project_id, notification_config_id } = pathParams;
 
-  return `/api/projects/${project_id}/notifications/${notification_config_id}`;
+  return `/api/projects/${project_id}/notifications/config/${notification_config_id}`;
+});
+
+const getNotification = baseApi<
+  {},
+  {
+    project_id: number;
+    notification_id: string;
+  }
+>("GET", (pathParams) => {
+  const { project_id, notification_id } = pathParams;
+
+  return `/api/projects/${project_id}/notifications/${notification_id}`;
 });
 
 const getPRDeploymentList = baseApi<
@@ -3476,6 +3488,7 @@ export default {
   updateNotificationConfig,
   legacyGetNotificationConfig,
   getNotificationConfig,
+  getNotification,
   createSubdomain,
   deployTemplate,
   deployAddon,

+ 12 - 0
internal/repository/gorm/porter_app_event.go

@@ -189,6 +189,18 @@ func (repo *PorterAppEventRepository) ReadNotificationsByAppRevisionID(ctx conte
 	return notifications, nil
 }
 
+// NotificationByID returns a notification by the notification id
+func (repo *PorterAppEventRepository) NotificationByID(ctx context.Context, notificationID string) (*models.PorterAppEvent, error) {
+	notification := &models.PorterAppEvent{}
+
+	// TODO: make app_revision_id a column in porter_app_event table: https://linear.app/porter/issue/POR-2096/add-app-revision-id-column-to-porter-app-events-table
+	if err := repo.db.Where("type = 'NOTIFICATION' AND metadata->>'id' = ?", notificationID).Find(&notification).Error; err != nil {
+		return notification, err
+	}
+
+	return notification, nil
+}
+
 func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error) {
 	appEvent := models.PorterAppEvent{}
 

+ 1 - 0
internal/repository/porter_app_event.go

@@ -21,4 +21,5 @@ type PorterAppEventRepository interface {
 	// ReadDeployEventByAppRevisionID returns a deploy event for a given porter app id and app revision ID
 	ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error)
 	ReadNotificationsByAppRevisionID(ctx context.Context, porterAppInstanceID uuid.UUID, appRevisionID string) ([]*models.PorterAppEvent, error)
+	NotificationByID(ctx context.Context, notificationID string) (*models.PorterAppEvent, error)
 }

+ 5 - 0
internal/repository/test/porter_app_event.go

@@ -57,3 +57,8 @@ func (repo *PorterAppEventRepository) ReadDeployEventByAppRevisionID(ctx context
 func (repo *PorterAppEventRepository) ReadNotificationsByAppRevisionID(ctx context.Context, porterAppInstanceID uuid.UUID, appRevisionID string) ([]*models.PorterAppEvent, error) {
 	return nil, errors.New("cannot read database")
 }
+
+// NotificationByID returns a notification by the notification id
+func (repo *PorterAppEventRepository) NotificationByID(ctx context.Context, notificationID string) (*models.PorterAppEvent, error) {
+	return nil, errors.New("cannot read database")
+}