diff --git a/api/internal/features/notification/init.go b/api/internal/features/notification/init.go index 0eaede7e5..3a5052f35 100644 --- a/api/internal/features/notification/init.go +++ b/api/internal/features/notification/init.go @@ -181,6 +181,32 @@ func (m *NotificationManager) SendOrganizationNotification(payload NotificationP } m.sendWebhookNotification(payload.UserID, fmt.Sprintf("User %s (%s) removed from organization %s", data.UserName, data.UserEmail, data.OrganizationName)) } + case NortificationPayloadTypeCreateOrganization: + if org, ok := payload.Data.(shared_types.Organization); ok { + message := fmt.Sprintf("Organization '%s' has been created successfully", org.Name) + m.sendWebhookNotification(payload.UserID, message) + log.Printf("Organization created notification sent for organization: %s", org.Name) + } + case NotificationPayloadTypeUpdateOrganization: + if org, ok := payload.Data.(shared_types.Organization); ok { + message := fmt.Sprintf("Organization '%s' has been updated", org.Name) + m.sendWebhookNotification(payload.UserID, message) + log.Printf("Organization updated notification sent for organization: %s", org.Name) + } else if orgReq, ok := payload.Data.(*NotificationOrganizationData); ok { + message := fmt.Sprintf("Organization (ID: %s) has been updated", orgReq.OrganizationID) + m.sendWebhookNotification(payload.UserID, message) + log.Printf("Organization updated notification sent for organization ID: %s", orgReq.OrganizationID) + } + case NotificationPayloadTypeDeleteOrganization: + if orgReq, ok := payload.Data.(shared_types.Organization); ok { + message := fmt.Sprintf("Organization '%s' has been deleted", orgReq.Name) + m.sendWebhookNotification(payload.UserID, message) + log.Printf("Organization deleted notification sent for organization: %s", orgReq.Name) + } else if orgReq, ok := payload.Data.(NotificationOrganizationData); ok { + message := fmt.Sprintf("Organization (ID: %s) has been deleted", orgReq.OrganizationID) + m.sendWebhookNotification(payload.UserID, message) + log.Printf("Organization deleted notification sent for organization ID: %s", orgReq.OrganizationID) + } } } diff --git a/api/internal/features/organization/controller/create_organization.go b/api/internal/features/organization/controller/create_organization.go index dde18a226..26168d299 100644 --- a/api/internal/features/organization/controller/create_organization.go +++ b/api/internal/features/organization/controller/create_organization.go @@ -6,6 +6,7 @@ import ( "github.com/go-fuego/fuego" "github.com/raghavyuva/nixopus-api/internal/features/logger" + "github.com/raghavyuva/nixopus-api/internal/features/notification" "github.com/raghavyuva/nixopus-api/internal/features/organization/types" "github.com/raghavyuva/nixopus-api/internal/features/supertokens" "github.com/raghavyuva/nixopus-api/internal/utils" @@ -59,7 +60,7 @@ func (c *OrganizationsController) CreateOrganization(f fuego.ContextWithBody[typ // Don't fail the entire operation for role creation failure } - // c.Notify(notification.NortificationPayloadTypeCreateOrganization, loggedInUser, r, createdOrganization) + c.Notify(notification.NortificationPayloadTypeCreateOrganization, loggedInUser, r, createdOrganization) return &types.OrganizationResponse{ Status: "success", diff --git a/api/internal/features/organization/controller/delete_organization.go b/api/internal/features/organization/controller/delete_organization.go index 0ba7c7ee5..2b6a64f5b 100644 --- a/api/internal/features/organization/controller/delete_organization.go +++ b/api/internal/features/organization/controller/delete_organization.go @@ -6,6 +6,7 @@ import ( "github.com/go-fuego/fuego" "github.com/google/uuid" "github.com/raghavyuva/nixopus-api/internal/features/logger" + "github.com/raghavyuva/nixopus-api/internal/features/notification" "github.com/raghavyuva/nixopus-api/internal/features/organization/types" "github.com/raghavyuva/nixopus-api/internal/utils" ) @@ -63,6 +64,15 @@ func (c *OrganizationsController) DeleteOrganization(f fuego.ContextWithBody[typ } } + // Get organization details before deletion for notification + orgToDelete, err := c.service.GetOrganization(organization.ID) + if err != nil { + return nil, fuego.HTTPError{ + Err: err, + Status: http.StatusInternalServerError, + } + } + if err := c.service.DeleteOrganization(organizationID); err != nil { return nil, fuego.HTTPError{ Err: err, @@ -70,7 +80,7 @@ func (c *OrganizationsController) DeleteOrganization(f fuego.ContextWithBody[typ } } - // c.Notify(notification.NotificationPayloadTypeDeleteOrganization, loggedInUser, r) + c.Notify(notification.NotificationPayloadTypeDeleteOrganization, loggedInUser, r, orgToDelete) return &types.MessageResponse{ Status: "success", diff --git a/api/internal/features/organization/controller/remove_organization_user.go b/api/internal/features/organization/controller/remove_organization_user.go index 86f20a0d4..a7404a2d5 100644 --- a/api/internal/features/organization/controller/remove_organization_user.go +++ b/api/internal/features/organization/controller/remove_organization_user.go @@ -5,7 +5,9 @@ import ( "github.com/go-fuego/fuego" "github.com/raghavyuva/nixopus-api/internal/features/logger" + "github.com/raghavyuva/nixopus-api/internal/features/notification" "github.com/raghavyuva/nixopus-api/internal/features/organization/types" + shared_types "github.com/raghavyuva/nixopus-api/internal/types" "github.com/raghavyuva/nixopus-api/internal/utils" ) @@ -36,6 +38,45 @@ func (c *OrganizationsController) RemoveUserFromOrganization(f fuego.ContextWith } } + // Get organization details before removal for notification + organization, err := c.service.GetOrganization(user.OrganizationID) + if err != nil { + return nil, fuego.HTTPError{ + Err: err, + Status: http.StatusInternalServerError, + } + } + + // Get user details - we'll fetch from the service's internal storage + // Since the service already fetches this, we'll get it after removal + // by querying the organization users or we can modify service to return it + // For now, let's get it from the request context or fetch after removal + + // Note: The service already fetches the user, but we need it before removal + // We'll need to modify the approach - get user info from organization users list + orgUsers, err := c.service.GetOrganizationUsersWithRoles(user.OrganizationID) + if err != nil { + return nil, fuego.HTTPError{ + Err: err, + Status: http.StatusInternalServerError, + } + } + + var removedUser *shared_types.User + for _, orgUser := range orgUsers { + if orgUser.User.ID.String() == user.UserID { + removedUser = orgUser.User + break + } + } + + if removedUser == nil { + return nil, fuego.HTTPError{ + Err: types.ErrUserNotInOrganization, + Status: http.StatusBadRequest, + } + } + if err := c.service.RemoveUserFromOrganization(&user); err != nil { return nil, fuego.HTTPError{ Err: err, @@ -43,6 +84,19 @@ func (c *OrganizationsController) RemoveUserFromOrganization(f fuego.ContextWith } } + // Send notification to organization admins about user removal + notificationData := notification.RemoveUserFromOrganizationData{ + NotificationBaseData: notification.NotificationBaseData{ + IP: r.RemoteAddr, + Browser: r.UserAgent(), + }, + OrganizationName: organization.Name, + UserName: removedUser.Username, + UserEmail: removedUser.Email, + } + + c.Notify(notification.NotificationPayloadTypeRemoveUserFromOrganization, loggedInUser, r, notificationData) + return &types.MessageResponse{ Status: "success", Message: "User removed from organization successfully", diff --git a/api/internal/features/organization/controller/update_organization.go b/api/internal/features/organization/controller/update_organization.go index f732f71c9..4064ae1b3 100644 --- a/api/internal/features/organization/controller/update_organization.go +++ b/api/internal/features/organization/controller/update_organization.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/go-fuego/fuego" + "github.com/raghavyuva/nixopus-api/internal/features/notification" "github.com/raghavyuva/nixopus-api/internal/features/organization/types" "github.com/raghavyuva/nixopus-api/internal/utils" ) @@ -33,7 +34,11 @@ func (c *OrganizationsController) UpdateOrganization(f fuego.ContextWithBody[typ } } - // c.Notify(notification.NotificationPayloadTypeUpdateOrganization, loggedInUser, r) + // Get updated organization for notification + updatedOrg, err := c.service.GetOrganization(organization.ID) + if err == nil { + c.Notify(notification.NotificationPayloadTypeUpdateOrganization, loggedInUser, r, updatedOrg) + } return &types.MessageResponse{ Status: "success", diff --git a/view/app/settings/hooks/use-notification-settings.ts b/view/app/settings/hooks/use-notification-settings.ts index 0fcc486d3..6eca68cb0 100644 --- a/view/app/settings/hooks/use-notification-settings.ts +++ b/view/app/settings/hooks/use-notification-settings.ts @@ -103,36 +103,32 @@ function useNotificationSettings() { useUpdateNotificationPreferencesMutation(); const handleOnSave = async (data: SMTPFormData) => { - try { - const smtpConfig = { - host: data.smtp_host, - port: parseInt(data.smtp_port), - username: data.smtp_username, - password: data.smtp_password, - from_email: data.smtp_from_email, - from_name: data.smtp_from_name - }; - if (smtpConfigs?.id) { - await handleUpdateSMTPConfiguration({ - ...smtpConfig, - id: smtpConfigs.id, - organization_id: activeOrganization?.id || '' - }); - } else { - await handleCreateSMTPConfiguration({ - ...smtpConfig, - organization_id: activeOrganization?.id || '' - }); - } - } catch (error) { - toast.error(t('settings.notifications.messages.email.error')); + const smtpConfig = { + host: data.smtp_host, + port: parseInt(data.smtp_port), + username: data.smtp_username, + password: data.smtp_password, + from_email: data.smtp_from_email, + from_name: data.smtp_from_name + }; + if (smtpConfigs?.id) { + await handleUpdateSMTPConfiguration({ + ...smtpConfig, + id: smtpConfigs.id, + organization_id: activeOrganization?.id || '' + }); + } else { + await handleCreateSMTPConfiguration({ + ...smtpConfig, + organization_id: activeOrganization?.id || '' + }); } }; const handleUpdatePreference = async (id: string, enabled: boolean) => { + const category = getPreferenceCategoryFromId(id); + const type = getPreferenceTypeFromId(id); try { - const category = getPreferenceCategoryFromId(id); - const type = getPreferenceTypeFromId(id); await updateNotificationPreferences({ category, type, @@ -187,6 +183,17 @@ function useNotificationSettings() { } }; + const isLoading = + isLoadingSMTP || + isLoadingSlack || + isLoadingDiscord || + isLoadingPreferences || + isCreatingSMTP || + isUpdatingSMTP || + isCreatingWebhook || + isUpdatingWebhook || + isUpdatingPreferences; + return { smtpConfigs, slackConfig, @@ -196,16 +203,11 @@ function useNotificationSettings() { handleUpdateWebhookConfig, handleDeleteWebhookConfig, preferences, - isLoading: - isLoadingSMTP || - isLoadingSlack || - isLoadingDiscord || - isLoadingPreferences || - isCreatingSMTP || - isUpdatingSMTP || - isCreatingWebhook || - isUpdatingWebhook || - isUpdatingPreferences, + isLoading, + isLoadingPreferences, + isLoadingSMTP, + isLoadingSlack, + isLoadingDiscord, error: smtpError || slackError || discordError, handleUpdatePreference }; diff --git a/view/app/settings/notifications/components/channelTab.tsx b/view/app/settings/notifications/components/channelTab.tsx index 253169fb4..ec4b8a8bf 100644 --- a/view/app/settings/notifications/components/channelTab.tsx +++ b/view/app/settings/notifications/components/channelTab.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Mail, Slack, MessageSquare } from 'lucide-react'; import { useTranslation } from '@/hooks/use-translation'; import { SMTPConfig, WebhookConfig, SMTPFormData } from '@/redux/types/notification'; @@ -17,7 +17,6 @@ import { FormLabel, FormMessage } from '@/components/ui/form'; -import { toast } from 'sonner'; import { TypographySmall, TypographyMuted } from '@/components/ui/typography'; import { PasswordInputField } from '@/components/ui/password-input-field'; @@ -61,90 +60,96 @@ const ChannelTab: React.FC = ({ }) => { const { t } = useTranslation(); - const emailForm = useForm>({ - resolver: zodResolver(emailFormSchema), - defaultValues: { + const emailDefaultValues = useMemo( + () => ({ smtp_host: smtpConfigs?.host || '', smtp_port: smtpConfigs?.port?.toString() || '', smtp_username: smtpConfigs?.username || '', smtp_password: smtpConfigs?.password || '', smtp_from_email: smtpConfigs?.from_email || '', smtp_from_name: smtpConfigs?.from_name || '' - } + }), + [smtpConfigs] + ); + + const slackDefaultValues = useMemo( + () => ({ + webhook_url: slackConfig?.webhook_url || '', + is_active: slackConfig?.is_active ?? true + }), + [slackConfig] + ); + + const discordDefaultValues = useMemo( + () => ({ + webhook_url: discordConfig?.webhook_url || '', + is_active: discordConfig?.is_active ?? true + }), + [discordConfig] + ); + + const emailForm = useForm>({ + resolver: zodResolver(emailFormSchema), + defaultValues: emailDefaultValues }); const slackForm = useForm>({ resolver: zodResolver(slackFormSchema), - defaultValues: { - webhook_url: slackConfig?.webhook_url || '', - is_active: slackConfig?.is_active ?? true - } + defaultValues: slackDefaultValues }); const discordForm = useForm>({ resolver: zodResolver(discordFormSchema), - defaultValues: { - webhook_url: discordConfig?.webhook_url || '', - is_active: discordConfig?.is_active ?? true - } + defaultValues: discordDefaultValues }); useEffect(() => { if (slackConfig) { - slackForm.setValue('webhook_url', slackConfig.webhook_url || ''); - slackForm.setValue('is_active', slackConfig.is_active); + slackForm.reset({ + webhook_url: slackConfig.webhook_url || '', + is_active: slackConfig.is_active ?? true + }); } - }, [slackConfig]); + }, [slackConfig, slackForm]); useEffect(() => { if (discordConfig) { - discordForm.setValue('webhook_url', discordConfig.webhook_url || ''); - discordForm.setValue('is_active', discordConfig.is_active); + discordForm.reset({ + webhook_url: discordConfig.webhook_url || '', + is_active: discordConfig.is_active ?? true + }); } - }, [discordConfig]); + }, [discordConfig, discordForm]); useEffect(() => { if (smtpConfigs) { - emailForm.setValue('smtp_host', smtpConfigs.host || ''); - emailForm.setValue('smtp_port', smtpConfigs.port?.toString() || ''); - emailForm.setValue('smtp_username', smtpConfigs.username || ''); - emailForm.setValue('smtp_password', smtpConfigs.password || ''); - emailForm.setValue('smtp_from_email', smtpConfigs.from_email || ''); - emailForm.setValue('smtp_from_name', smtpConfigs.from_name || ''); + emailForm.reset({ + smtp_host: smtpConfigs.host || '', + smtp_port: smtpConfigs.port?.toString() || '', + smtp_username: smtpConfigs.username || '', + smtp_password: smtpConfigs.password || '', + smtp_from_email: smtpConfigs.from_email || '', + smtp_from_name: smtpConfigs.from_name || '' + }); } - }, [smtpConfigs]); + }, [smtpConfigs, emailForm]); const onSubmitEmail = async (data: SMTPFormData) => { - try { - await handleOnSave(data); - toast.success(t('settings.notifications.messages.email.success')); - } catch (error) { - toast.error(t('settings.notifications.messages.email.error')); - } + await handleOnSave(data); }; const onSubmitSlack = async (data: z.infer) => { - try { - await handleOnSaveSlack({ - webhook_url: data.webhook_url, - is_active: data.is_active.toString() - }); - toast.success(t('settings.notifications.messages.slack.success')); - } catch (error) { - toast.error(t('settings.notifications.messages.slack.error')); - } + await handleOnSaveSlack({ + webhook_url: data.webhook_url, + is_active: data.is_active.toString() + }); }; const onSubmitDiscord = async (data: z.infer) => { - try { - await handleOnSaveDiscord({ - webhook_url: data.webhook_url, - is_active: data.is_active.toString() - }); - toast.success(t('settings.notifications.messages.discord.success')); - } catch (error) { - toast.error(t('settings.notifications.messages.discord.error')); - } + await handleOnSaveDiscord({ + webhook_url: data.webhook_url, + is_active: data.is_active.toString() + }); }; return ( diff --git a/view/app/settings/notifications/components/preference.tsx b/view/app/settings/notifications/components/preference.tsx index 69423a0d9..74e2b5f0e 100644 --- a/view/app/settings/notifications/components/preference.tsx +++ b/view/app/settings/notifications/components/preference.tsx @@ -35,7 +35,7 @@ const NotificationPreferenceCard: React.FC = ({ onUpdate?.(pref.id, enabled)} /> diff --git a/view/components/settings/NotificationsSettingsContent.tsx b/view/components/settings/NotificationsSettingsContent.tsx index db3deb183..dda27af71 100644 --- a/view/components/settings/NotificationsSettingsContent.tsx +++ b/view/components/settings/NotificationsSettingsContent.tsx @@ -7,12 +7,14 @@ import useNotificationSettings from '@/app/settings/hooks/use-notification-setti import NotificationChannelsTab from '@/app/settings/notifications/components/channelTab'; import NotificationPreferencesTab from '@/app/settings/notifications/components/preferenceTab'; import { SMTPFormData } from '@/redux/types/notification'; +import { Skeleton } from '@/components/ui/skeleton'; export function NotificationsSettingsContent() { const { t } = useTranslation(); const settings = useNotificationSettings(); const handleSave = (data: SMTPFormData) => settings.handleOnSave(data); + const handleSaveSlack = (data: Record) => { settings.slackConfig ? settings.handleUpdateWebhookConfig({ @@ -22,6 +24,7 @@ export function NotificationsSettingsContent() { }) : settings.handleCreateWebhookConfig({ type: 'slack', webhook_url: data.webhook_url }); }; + const handleSaveDiscord = (data: Record) => { settings.discordConfig ? settings.handleUpdateWebhookConfig({ @@ -32,10 +35,19 @@ export function NotificationsSettingsContent() { : settings.handleCreateWebhookConfig({ type: 'discord', webhook_url: data.webhook_url }); }; + if (settings.isLoadingPreferences) { + return ( +
+ + +
+ ); + } + return ( -
+

{t('settings.notifications.page.title')}

- + @@ -47,7 +59,7 @@ export function NotificationsSettingsContent() { - + - +