Alexander Belanger 4 лет назад
Родитель
Сommit
bf939f7dd1

+ 38 - 50
dashboard/src/components/SaveButton.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 
 
-type PropsType = {
+type Props = {
   text?: string;
   text?: string;
   onClick: () => void;
   onClick: () => void;
   disabled?: boolean;
   disabled?: boolean;
@@ -10,6 +10,7 @@ type PropsType = {
   color?: string;
   color?: string;
   rounded?: boolean;
   rounded?: boolean;
   helper?: string | null;
   helper?: string | null;
+  saveText?: string | null;
 
 
   // Makes flush with corner if not within a modal
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
   makeFlush?: boolean;
@@ -17,82 +18,69 @@ type PropsType = {
   statusPosition?: "right" | "left";
   statusPosition?: "right" | "left";
 };
 };
 
 
-type StateType = {};
-
-export default class SaveButton extends Component<PropsType, StateType> {
-  renderStatus = () => {
-    if (this.props.status) {
-      if (this.props.status === "successful") {
+const SaveButton: React.FC<Props> = (props) => {
+  const renderStatus = () => {
+    if (props.status) {
+      if (props.status === "successful") {
         return (
         return (
-          <StatusWrapper position={this.props.statusPosition} successful={true}>
+          <StatusWrapper position={props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
             <i className="material-icons">done</i>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "loading") {
+      } else if (props.status === "loading") {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <LoadingGif src={loading} />
             <LoadingGif src={loading} />
-            <StatusTextWrapper>Updating . . .</StatusTextWrapper>
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "error") {
+      } else if (props.status === "error") {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
       } else {
       } else {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
             <i className="material-icons">error_outline</i>
-            <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
       }
       }
-    } else if (this.props.helper) {
+    } else if (props.helper) {
       return (
       return (
-        <StatusWrapper position={this.props.statusPosition} successful={true}>
-          {this.props.helper}
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
         </StatusWrapper>
         </StatusWrapper>
       );
       );
     }
     }
   };
   };
 
 
-  render() {
-    return (
-      <ButtonWrapper
-        makeFlush={this.props.makeFlush}
-        clearPosition={this.props.clearPosition}
+  return (
+    <ButtonWrapper
+      makeFlush={props.makeFlush}
+      clearPosition={props.clearPosition}
+    >
+      {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
+      <Button
+        rounded={props.rounded}
+        disabled={props.disabled}
+        onClick={props.onClick}
+        color={props.color || "#616FEEcc"}
       >
       >
-        {this.props.statusPosition !== "right" && (
-          <div>{this.renderStatus()}</div>
-        )}
-        <Button
-          rounded={this.props.rounded}
-          disabled={this.props.disabled}
-          onClick={this.props.onClick}
-          color={this.props.color || "#616FEEcc"}
-        >
-          {this.props.children || this.props.text}
-        </Button>
-        {this.props.statusPosition === "right" && (
-          <div>{this.renderStatus()}</div>
-        )}
-      </ButtonWrapper>
-    );
-  }
-}
+        {props.children || props.text}
+      </Button>
+      {props.statusPosition === "right" && <div>{renderStatus()}</div>}
+    </ButtonWrapper>
+  );
+};
+
+export default SaveButton;
 
 
 const LoadingGif = styled.img`
 const LoadingGif = styled.img`
   width: 15px;
   width: 15px;

+ 170 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx

@@ -0,0 +1,170 @@
+import React, { useContext, useState, useEffect } from "react";
+import Heading from "../../../../components/form-components/Heading";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import Helper from "../../../../components/form-components/Helper";
+import SaveButton from "../../../../components/SaveButton";
+import api from "../../../../shared/api";
+import { Context } from "../../../../shared/Context";
+import { ChartType } from "../../../../shared/types";
+import Loading from "../../../../components/Loading";
+
+const NOTIF_CATEGORIES = ["success", "fail"];
+
+interface Props {
+  disabled?: boolean;
+  currentChart: ChartType;
+}
+
+const NotificationSettingsSection: React.FC<Props> = (props) => {
+  const [notificationsOn, setNotificationsOn] = useState(true);
+  const [categories, setCategories] = useState(
+    NOTIF_CATEGORIES.reduce((p, c) => {
+      return {
+        ...p,
+        [c]: true,
+      };
+    }, {})
+  );
+  const [initLoading, setInitLoading] = useState(true);
+  const [saveLoading, setSaveLoading] = useState(false);
+  const [numSaves, setNumSaves] = useState(0);
+  const [hasNotifications, setHasNotifications] = useState(null);
+  const [hasRelease, setHasRelease] = useState(true);
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  useEffect(() => {
+    api
+      .getNotificationConfig(
+        "<token>",
+        {
+          namespace: props.currentChart.namespace,
+          cluster_id: currentCluster.id,
+        },
+        {
+          project_id: currentProject.id,
+          name: props.currentChart.name,
+        }
+      )
+      .then(({ data }) => {
+        setNotificationsOn(data.enabled);
+        delete data.enabled;
+        setCategories({
+          success: data.success,
+          failure: data.failure,
+        });
+        setInitLoading(false);
+      })
+      .catch(() => {
+        setHasRelease(false);
+        setInitLoading(false);
+      });
+    api
+      .getSlackIntegrations(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setHasNotifications(data.length > 0);
+      });
+  }, []);
+
+  const saveChanges = () => {
+    setSaveLoading(true);
+    let payload = {
+      enabled: notificationsOn,
+      ...categories,
+    };
+
+    api
+      .updateNotificationConfig(
+        "<token>",
+        {
+          namespace: props.currentChart.namespace,
+          cluster_id: currentCluster.id,
+          payload,
+        },
+        {
+          project_id: currentProject.id,
+          name: props.currentChart.name,
+        }
+      )
+      .then(() => {
+        setNumSaves(numSaves + 1);
+        setSaveLoading(false);
+      })
+      .catch(() => {
+        setHasRelease(false);
+        setSaveLoading(false);
+      });
+  };
+
+  return (
+    <>
+      <Heading>Notification Settings</Heading>
+      {initLoading ? (
+        <Loading />
+      ) : !hasRelease ? (
+        <Heading>
+          This message appears when the release isn't in the database, so Porter
+          can't laod in notifications for it
+        </Heading>
+      ) : (
+        <>
+          {hasNotifications != null && !hasNotifications && (
+            <Helper>
+              This message appears when there are no notification integrations
+              for the project
+            </Helper>
+          )}
+          <CheckboxRow
+            label={"Notifications Enabled"}
+            checked={notificationsOn}
+            toggle={() => setNotificationsOn(!notificationsOn)}
+            disabled={props.disabled}
+          />
+          {notificationsOn && (
+            <>
+              <Helper>Send notifications on:</Helper>
+              {Object.entries(categories).map(([k, v]: [string, boolean]) => {
+                return (
+                  <React.Fragment key={k}>
+                    <CheckboxRow
+                      label={k}
+                      checked={v}
+                      toggle={() =>
+                        setCategories((prev) => {
+                          return {
+                            ...prev,
+                            [k]: !v,
+                          };
+                        })
+                      }
+                      disabled={props.disabled}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </>
+          )}
+          <SaveButton
+            onClick={() => saveChanges()}
+            text={"Save Changes"}
+            clearPosition={true}
+            statusPosition={"right"}
+            disabled={props.disabled || initLoading || saveLoading}
+            status={
+              saveLoading ? "loading" : numSaves > 0 ? "successful" : null
+            }
+            saveText={"Saving . . ."}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default NotificationSettingsSection;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -14,6 +14,7 @@ import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
+import NotificationSettingsSection from "./NotificationSettingsSection";
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
@@ -258,6 +259,7 @@ const SettingsSection: React.FC<PropsType> = ({
       {!loadingWebhookToken ? (
       {!loadingWebhookToken ? (
         <StyledSettingsSection showSource={showSource}>
         <StyledSettingsSection showSource={showSource}>
           {renderWebhookSection()}
           {renderWebhookSection()}
+          <NotificationSettingsSection currentChart={currentChart} />
           <Heading>Additional Settings</Heading>
           <Heading>Additional Settings</Heading>
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
             Delete {currentChart.name}
             Delete {currentChart.name}

+ 29 - 0
dashboard/src/shared/api.tsx

@@ -267,6 +267,33 @@ const deleteSlackIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
 });
 });
 
 
+const updateNotificationConfig = baseApi<
+  {
+    payload: any;
+    namespace: string;
+    cluster_id: number;
+  },
+  {
+    project_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
+});
+
+const getNotificationConfig = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  {
+    project_id: number;
+    name: string;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
+});
+
 const deployTemplate = baseApi<
 const deployTemplate = baseApi<
   {
   {
     templateName: string;
     templateName: string;
@@ -1081,6 +1108,8 @@ export default {
   deleteProject,
   deleteProject,
   deleteRegistryIntegration,
   deleteRegistryIntegration,
   deleteSlackIntegration,
   deleteSlackIntegration,
+  updateNotificationConfig,
+  getNotificationConfig,
   createSubdomain,
   createSubdomain,
   deployTemplate,
   deployTemplate,
   deployAddon,
   deployAddon,

+ 16 - 1
internal/integrations/slack/notifier.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"bytes"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
+	"github.com/porter-dev/porter/internal/models"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -52,11 +53,13 @@ type NotifyOpts struct {
 
 
 type SlackNotifier struct {
 type SlackNotifier struct {
 	slackInts []*integrations.SlackIntegration
 	slackInts []*integrations.SlackIntegration
+	Config    *models.NotificationConfigExternal
 }
 }
 
 
-func NewSlackNotifier(slackInts ...*integrations.SlackIntegration) Notifier {
+func NewSlackNotifier(conf *models.NotificationConfigExternal, slackInts ...*integrations.SlackIntegration) Notifier {
 	return &SlackNotifier{
 	return &SlackNotifier{
 		slackInts: slackInts,
 		slackInts: slackInts,
+		Config:    conf,
 	}
 	}
 }
 }
 
 
@@ -75,6 +78,18 @@ type SlackText struct {
 }
 }
 
 
 func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
+	if s.Config != nil {
+		if !s.Config.Enabled {
+			return nil
+		}
+		if opts.Status == StatusDeployed && !s.Config.Success {
+			return nil
+		}
+		if opts.Status == StatusFailed && !s.Config.Failure {
+			return nil
+		}
+	}
+
 	blocks := []*SlackBlock{
 	blocks := []*SlackBlock{
 		getMessageBlock(opts),
 		getMessageBlock(opts),
 		getDividerBlock(),
 		getDividerBlock(),

+ 26 - 0
internal/models/notification.go

@@ -0,0 +1,26 @@
+package models
+
+import "gorm.io/gorm"
+
+type NotificationConfig struct {
+	gorm.Model
+
+	Enabled bool `gorm:"default:true"` // if notifications are enabled at all
+
+	Success bool `gorm:"default:true"`
+	Failure bool `gorm:"default:true"`
+}
+
+type NotificationConfigExternal struct {
+	Enabled bool `json:"enabled"`
+	Success bool `json:"success"`
+	Failure bool `json:"failure"`
+}
+
+func (conf *NotificationConfig) Externalize() *NotificationConfigExternal {
+	return &NotificationConfigExternal{
+		Enabled: conf.Enabled,
+		Success: conf.Success,
+		Failure: conf.Failure,
+	}
+}

+ 2 - 1
internal/models/release.go

@@ -20,7 +20,8 @@ type Release struct {
 	// but this should be used for the source of truth going forward.
 	// but this should be used for the source of truth going forward.
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 
 
-	GitActionConfig GitActionConfig `json:"git_action_config"`
+	GitActionConfig    GitActionConfig `json:"git_action_config"`
+	NotificationConfig uint
 }
 }
 
 
 // ReleaseExternal represents the Release type that is sent over REST
 // ReleaseExternal represents the Release type that is sent over REST

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -27,6 +27,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.DNSRecord{},
 		&models.DNSRecord{},
 		&models.PWResetToken{},
 		&models.PWResetToken{},
 		&models.Event{},
 		&models.Event{},
+		&models.NotificationConfig{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 44 - 0
internal/repository/gorm/notification.go

@@ -0,0 +1,44 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type NotificationConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewNotificationConfigRepository creates a new NotificationConfigRepository
+func NewNotificationConfigRepository(db *gorm.DB) repository.NotificationConfigRepository {
+	return NotificationConfigRepository{db: db}
+}
+
+// CreateNotificationConfig creates a new NotificationConfig
+func (repo NotificationConfigRepository) CreateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadNotificationConfig reads a NotificationConfig by Id
+func (repo NotificationConfigRepository) ReadNotificationConfig(id uint) (*models.NotificationConfig, error) {
+	ret := &models.NotificationConfig{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// UpdateNotificationConfig updates a given NotificationConfig
+func (repo NotificationConfigRepository) UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error) {
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -33,5 +33,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		SlackIntegration:          NewSlackIntegrationRepository(db, key),
 		SlackIntegration:          NewSlackIntegrationRepository(db, key),
+		NotificationConfig:        NewNotificationConfigRepository(db),
 	}
 	}
 }
 }

+ 11 - 0
internal/repository/notification.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotificationConfigRepository interface {
+	CreateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error)
+	ReadNotificationConfig(id uint) (*models.NotificationConfig, error)
+	UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -26,4 +26,5 @@ type Repository struct {
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	SlackIntegration          SlackIntegrationRepository
 	SlackIntegration          SlackIntegrationRepository
+	NotificationConfig        NotificationConfigRepository
 }
 }

+ 137 - 0
server/api/notifications_handler.go

@@ -0,0 +1,137 @@
+package api
+
+import (
+	"encoding/json"
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"net/http"
+	"net/url"
+	"strconv"
+)
+
+type HandleUpdateNotificationConfigForm struct {
+	Payload struct {
+		Enabled bool `json:"enabled"`
+		Success bool `json:"success"`
+		Failure bool `json:"failure"`
+	} `json:"payload"`
+	Namespace string `json:"namespace"`
+	ClusterID uint   `json:"cluster_id"`
+}
+
+// HandleUpdateNotificationConfig updates notification settings for a given release
+func (app *App) HandleUpdateNotificationConfig(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &HandleUpdateNotificationConfigForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	release, err := app.Repo.Release.ReadRelease(form.ClusterID, name, form.Namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	// either create a new notification config or update the current one
+	newConfig := &models.NotificationConfig{
+		Enabled: form.Payload.Enabled,
+		Success: form.Payload.Success,
+		Failure: form.Payload.Failure,
+	}
+
+	if release.NotificationConfig == 0 {
+		newConfig, err = app.Repo.NotificationConfig.CreateNotificationConfig(newConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		release.NotificationConfig = newConfig.ID
+
+		release, err = app.Repo.Release.UpdateRelease(release)
+
+	} else {
+		newConfig.ID = release.NotificationConfig
+		newConfig, err = app.Repo.NotificationConfig.UpdateNotificationConfig(newConfig)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleGetNotificationConfig gets the notification config for a given release
+func (app *App) HandleGetNotificationConfig(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	name := chi.URLParam(r, "name")
+	namespace := vals["namespace"][0]
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	config := &models.NotificationConfigExternal{
+		Enabled: true,
+		Success: true,
+		Failure: true,
+	}
+
+	if release.NotificationConfig != 0 {
+		notifConfig, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+		}
+
+		config = notifConfig.Externalize()
+	}
+
+	err = json.NewEncoder(w).Encode(config)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+}

+ 23 - 0
server/api/oauth_slack_handler.go

@@ -129,6 +129,29 @@ func (app *App) HandleListSlackIntegrations(w http.ResponseWriter, r *http.Reque
 	}
 	}
 }
 }
 
 
+// HandleSlackIntegrationExists does 200 if at least one slack integration exists and 404 otherwise
+func (app *App) HandleSlackIntegrationExists(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	slackInts, err := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	if len(slackInts) != 0 {
+		w.WriteHeader(http.StatusOK)
+	} else {
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
 // HandleDeleteSlackIntegration deletes a slack integration for a project by ID
 // HandleDeleteSlackIntegration deletes a slack integration for a project by ID
 func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
 	// check that slack integration belongs to given project
 	// check that slack integration belongs to given project

+ 35 - 8
server/api/release_handler.go

@@ -1017,8 +1017,27 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		conf.Chart = chart
 		conf.Chart = chart
 	}
 	}
 
 
+	rel, err := agent.UpgradeRelease(conf, form.Values, app.DOConf)
+
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
-	notifier := slack.NewSlackNotifier(slackInts...)
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+	release, _ := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
+
+	var notifConf *models.NotificationConfigExternal
+	notifConf = nil
+	if release != nil && release.NotificationConfig != 0 {
+		conf, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		notifConf = conf.Externalize()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
 
 
 	notifyOpts := &slack.NotifyOpts{
 	notifyOpts := &slack.NotifyOpts{
 		ProjectID:   uint(projID),
 		ProjectID:   uint(projID),
@@ -1035,8 +1054,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		) + fmt.Sprintf("?project_id=%d", uint(projID)),
 		) + fmt.Sprintf("?project_id=%d", uint(projID)),
 	}
 	}
 
 
-	rel, err := agent.UpgradeRelease(conf, form.Values, app.DOConf)
-
 	if err != nil {
 	if err != nil {
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Info = err.Error()
 		notifyOpts.Info = err.Error()
@@ -1059,8 +1076,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 
 	// update the github actions env if the release exists and is built from source
 	// update the github actions env if the release exists and is built from source
 	if cName := rel.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 	if cName := rel.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
-		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
-
 		if err != nil {
 		if err != nil {
 			app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 				Code:   ErrReleaseReadData,
 				Code:   ErrReleaseReadData,
@@ -1070,8 +1085,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
-
 		if release != nil {
 		if release != nil {
 			// update image repo uri if changed
 			// update image repo uri if changed
 			repository := rel.Config["image"].(map[string]interface{})["repository"]
 			repository := rel.Config["image"].(map[string]interface{})["repository"]
@@ -1250,7 +1263,21 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
-	notifier := slack.NewSlackNotifier(slackInts...)
+
+	var notifConf *models.NotificationConfigExternal
+	notifConf = nil
+	if release != nil && release.NotificationConfig != 0 {
+		conf, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		notifConf = conf.Externalize()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
 
 
 	notifyOpts := &slack.NotifyOpts{
 	notifyOpts := &slack.NotifyOpts{
 		ProjectID:   uint(form.ReleaseForm.Cluster.ProjectID),
 		ProjectID:   uint(form.ReleaseForm.Cluster.ProjectID),

+ 31 - 0
server/router/router.go

@@ -929,6 +929,37 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/slack_integrations/exists",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleSlackIntegrationExists, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			// /projects/{project_id}/releases/{name}/notifications routes
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/{name}/notifications",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleUpdateNotificationConfig, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/releases/{name}/notifications",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleGetNotificationConfig, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/helmrepos routes
 			// /api/projects/{project_id}/helmrepos routes
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",