import set from 'lodash/set'; import {FieldObject} from 'sentry/components/forms/types'; import {t} from 'sentry/locale'; import {OrganizationSummary, Project} from 'sentry/types'; import { ALL_PROVIDERS, MIN_PROJECTS_FOR_CONFIRMATION, NOTIFICATION_SETTINGS_PATHNAMES, NotificationSettingsByProviderObject, NotificationSettingsObject, VALUE_MAPPING, } from 'sentry/views/settings/account/notifications/constants'; import {NOTIFICATION_SETTING_FIELDS} from 'sentry/views/settings/account/notifications/fields2'; import ParentLabel from 'sentry/views/settings/account/notifications/parentLabel'; /** * Which fine-tuning parts are grouped by project */ const notificationsByProject = ['alerts', 'email', 'workflow', 'spikeProtection']; export const isGroupedByProject = (notificationType: string): boolean => notificationsByProject.includes(notificationType); export const getParentKey = (notificationType: string): string => { return isGroupedByProject(notificationType) ? 'project' : 'organization'; }; export const groupByOrganization = ( projects: Project[] ): Record => { return projects.reduce< Record >((acc, project) => { const orgSlug = project.organization.slug; if (acc.hasOwnProperty(orgSlug)) { acc[orgSlug].projects.push(project); } else { acc[orgSlug] = { organization: project.organization, projects: [project], }; } return acc; }, {}); }; export const getFallBackValue = (notificationType: string): string => { switch (notificationType) { case 'alerts': return 'always'; case 'deploy': return 'committed_only'; case 'workflow': return 'subscribe_only'; case 'approval': return 'always'; case 'quota': return 'always'; case 'spikeProtection': return 'always'; case 'reports': return 'always'; default: // These are the expected potential settings with fallback of '' // issue, quotaErrors, quotaTransactions, quotaAttachments, // quotaReplays, quotaWarnings, quotaSpendAllocations return ''; } }; export const providerListToString = (providers: string[]): string => { return providers.sort().join('+'); }; export const getChoiceString = (choices: string[][], key: string): string => { if (!choices) { return 'default'; } const found = choices.find(row => row[0] === key); if (!found) { throw new Error(`Could not find ${key}`); } return found[1]; }; const isDataAllNever = (data: {[key: string]: string}): boolean => !!Object.keys(data).length && Object.values(data).every(value => value === 'never'); const getNonNeverValue = (data: {[key: string]: string}): string | null => Object.values(data).reduce( (previousValue: string | null, currentValue) => currentValue === 'never' ? previousValue : currentValue, null ); /** * Transform `data`, a mapping of providers to values, so that all providers in * `providerList` are "on" in the resulting object. The "on" value is * determined by checking `data` for non-"never" values and falling back to the * value `fallbackValue`. The "off" value is either "default" or "never" * depending on whether `scopeType` is "parent" or "user" respectively. */ export const backfillMissingProvidersWithFallback = ( data: {[key: string]: string}, providerList: string[], fallbackValue: string, scopeType: string ): NotificationSettingsByProviderObject => { // First pass: What was this scope's previous value? let existingValue; if (scopeType === 'user') { existingValue = isDataAllNever(data) ? fallbackValue : getNonNeverValue(data) || fallbackValue; } else { existingValue = isDataAllNever(data) ? 'never' : getNonNeverValue(data) || 'default'; } // Second pass: Fill in values for every provider. return Object.fromEntries( Object.keys(ALL_PROVIDERS).map(provider => [ provider, providerList.includes(provider) ? existingValue : 'never', ]) ); }; /** * Deeply merge N notification settings objects (usually just 2). */ export const mergeNotificationSettings = ( ...objects: NotificationSettingsObject[] ): NotificationSettingsObject => { const output: NotificationSettingsObject = {}; objects.forEach(settingsByType => Object.entries(settingsByType).forEach(([type, settingsByScopeType]) => Object.entries(settingsByScopeType).forEach(([scopeType, settingsByScopeId]) => Object.entries(settingsByScopeId).forEach(([scopeId, settingsByProvider]) => { set(output, [type, scopeType, scopeId].join('.'), settingsByProvider); }) ) ) ); return output; }; /** * Get the mapping of providers to values that describe a user's parent- * independent notification preferences. The data from the API uses the user ID * rather than "me" so we assume the first ID is the user's. */ export const getUserDefaultValues = ( notificationType: string, notificationSettings: NotificationSettingsObject ): NotificationSettingsByProviderObject => { return ( Object.values(notificationSettings[notificationType]?.user || {}).pop() || Object.fromEntries( Object.entries(ALL_PROVIDERS).map(([provider, value]) => [ provider, value === 'default' ? getFallBackValue(notificationType) : value, ]) ) ); }; /** * Get the list of providers currently active on this page. Note: this can be empty. */ export const getCurrentProviders = ( notificationType: string, notificationSettings: NotificationSettingsObject ): string[] => { const userData = getUserDefaultValues(notificationType, notificationSettings); return Object.entries(userData) .filter(([_, value]) => !['never'].includes(value)) .map(([provider, _]) => provider); }; /** * Calculate the currently selected provider. */ export const getCurrentDefault = ( notificationType: string, notificationSettings: NotificationSettingsObject ): string => { const providersList = getCurrentProviders(notificationType, notificationSettings); return providersList.length ? getUserDefaultValues(notificationType, notificationSettings)[providersList[0]] : 'never'; }; /** * For a given notificationType, are the parent-independent setting "never" for * all providers and are the parent-specific settings "default" or "never". If * so, the API is telling us that the user has opted out of all notifications. */ export const decideDefault = ( notificationType: string, notificationSettings: NotificationSettingsObject ): string => { const compare = (a: string, b: string): number => VALUE_MAPPING[a] - VALUE_MAPPING[b]; const parentIndependentSetting = Object.values(getUserDefaultValues(notificationType, notificationSettings)) .sort(compare) .pop() || 'never'; if (parentIndependentSetting !== 'never') { return parentIndependentSetting; } const parentSpecificSetting = Object.values( notificationSettings[notificationType]?.[getParentKey(notificationType)] || {} ) .flatMap(settingsByProvider => Object.values(settingsByProvider)) .sort(compare) .pop() || 'default'; return parentSpecificSetting === 'default' ? 'never' : parentSpecificSetting; }; /** * For a given notificationType, are the parent-independent setting "never" for * all providers and are the parent-specific settings "default" or "never"? If * so, the API is telling us that the user has opted out of all notifications. */ export const isEverythingDisabled = ( notificationType: string, notificationSettings: NotificationSettingsObject ): boolean => ['never', 'default'].includes(decideDefault(notificationType, notificationSettings)); /** * Extract either the list of project or organization IDs from the notification * settings in state. This assumes that the notification settings object is * fully backfilled with settings for every parent. */ export const getParentIds = ( notificationType: string, notificationSettings: NotificationSettingsObject ): string[] => Object.keys( notificationSettings[notificationType]?.[getParentKey(notificationType)] || {} ); export const getParentValues = ( notificationType: string, notificationSettings: NotificationSettingsObject, parentId: string ): NotificationSettingsByProviderObject => notificationSettings[notificationType]?.[getParentKey(notificationType)]?.[ parentId ] || { email: 'default', }; /** * Get a mapping of all parent IDs to the notification setting for the current * providers. */ export const getParentData = ( notificationType: string, notificationSettings: NotificationSettingsObject, parents: OrganizationSummary[] | Project[] ): NotificationSettingsByProviderObject => { const provider = getCurrentProviders(notificationType, notificationSettings)[0]; return Object.fromEntries( parents.map(parent => [ parent.id, getParentValues(notificationType, notificationSettings, parent.id)[provider], ]) ); }; /** * Are there are more than N project or organization settings? */ export const isSufficientlyComplex = ( notificationType: string, notificationSettings: NotificationSettingsObject ): boolean => getParentIds(notificationType, notificationSettings).length > MIN_PROJECTS_FOR_CONFIRMATION; /** * This is triggered when we change the Delivery Method select. Don't update the * provider for EVERY one of the user's projects and organizations, just the user * and parents that have explicit settings. */ export const getStateToPutForProvider = ( notificationType: string, notificationSettings: NotificationSettingsObject, changedData: NotificationSettingsByProviderObject ): NotificationSettingsObject => { const providerList: string[] = changedData.provider ? Object.values(changedData.provider) : []; const fallbackValue = getFallBackValue(notificationType); // If the user has no settings, we need to create them. if (!Object.keys(notificationSettings).length) { return { [notificationType]: { user: { me: Object.fromEntries(providerList.map(provider => [provider, fallbackValue])), }, }, }; } return { [notificationType]: Object.fromEntries( Object.entries(notificationSettings[notificationType]).map( ([scopeType, scopeTypeData]) => [ scopeType, Object.fromEntries( Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [ scopeId, backfillMissingProvidersWithFallback( scopeIdData, providerList, fallbackValue, scopeType ), ]) ), ] ) ), }; }; /** * Update the current providers' parent-independent notification settings with * the new value. If the new value is "never", then also update all * parent-specific notification settings to "default". If the previous value * was "never", then assume providerList should be "email" only. */ export const getStateToPutForDefault = ( notificationType: string, notificationSettings: NotificationSettingsObject, changedData: NotificationSettingsByProviderObject, parentIds: string[] ): NotificationSettingsObject => { const newValue = Object.values(changedData)[0]; let providerList = getCurrentProviders(notificationType, notificationSettings); if (!providerList.length) { providerList = ['email']; } const updatedNotificationSettings = { [notificationType]: { user: { me: Object.fromEntries(providerList.map(provider => [provider, newValue])), }, }, }; if (newValue === 'never') { updatedNotificationSettings[notificationType][getParentKey(notificationType)] = Object.fromEntries( parentIds.map(parentId => [ parentId, Object.fromEntries(providerList.map(provider => [provider, 'default'])), ]) ); } return updatedNotificationSettings; }; /** * Get the diff of the Notification Settings for this parent ID. */ export const getStateToPutForParent = ( notificationType: string, notificationSettings: NotificationSettingsObject, changedData: NotificationSettingsByProviderObject, parentId: string ): NotificationSettingsObject => { const providerList = getCurrentProviders(notificationType, notificationSettings); const newValue = Object.values(changedData)[0]; return { [notificationType]: { [getParentKey(notificationType)]: { [parentId]: Object.fromEntries( providerList.map(provider => [provider, newValue]) ), }, }, }; }; /** * Render each parent and add a default option to the the field choices. */ export const getParentField = ( notificationType: string, notificationSettings: NotificationSettingsObject, parent: OrganizationSummary | Project, onChange: ( changedData: NotificationSettingsByProviderObject, parentId: string ) => NotificationSettingsObject ): FieldObject => { const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType]; let choices = defaultFields.choices; if (Array.isArray(choices)) { choices = choices.concat([ [ 'default', `${t('Default')} (${getChoiceString( choices, getCurrentDefault(notificationType, notificationSettings) )})`, ], ]); } return Object.assign({}, defaultFields, { label: , getData: data => onChange(data, parent.id), name: parent.id, choices, defaultValue: 'default', help: undefined, }) as any; }; /** * Returns a link to docs on explaining how to manage quotas for that event type */ export function getDocsLinkForEventType( event: 'error' | 'transaction' | 'attachment' | 'replay' ) { switch (event) { case 'transaction': return 'https://docs.sentry.io/product/performance/transaction-summary/#what-is-a-transaction'; case 'attachment': return 'https://docs.sentry.io/product/accounts/quotas/manage-attachments-quota/#2-rate-limiting'; case 'replay': return 'https://docs.sentry.io/product/session-replay/'; default: return 'https://docs.sentry.io/product/accounts/quotas/manage-event-stream-guide/#common-workflows-for-managing-your-event-stream'; } } /** * Returns the corresponding notification type name from the router path name */ export function getNotificationTypeFromPathname(routerPathname: string) { const result = Object.entries(NOTIFICATION_SETTINGS_PATHNAMES).find( ([_, pathname]) => pathname === routerPathname ) ?? [routerPathname]; return result[0]; }