1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-17 11:54:22 +01:00

(feat) Admin can control its notifications preferences

This commit is contained in:
Karen 2023-02-02 13:37:09 +01:00 committed by Sylvain
parent 7a83a38c68
commit 10473182d4
21 changed files with 517 additions and 16 deletions

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# API Controller for resources of type Notification Preferences
class API::NotificationPreferencesController < API::ApiController
before_action :authenticate_user!
def index
@notification_preferences = current_user.notification_preferences
end
# Currently only available for Admin in NotificationPreferencePolicy
def update
authorize NotificationPreference
notification_type = NotificationType.find_by(name: params[:notification_preference][:notification_type])
@notification_preference = NotificationPreference.find_or_create_by(notification_type: notification_type, user: current_user)
@notification_preference.update(notification_preference_params)
if @notification_preference.save
render :show, status: :ok
else
render json: @notification_preference.errors, status: :unprocessable_entity
end
end
# Currently only available for Admin in NotificationPreferencePolicy
def bulk_update
authorize NotificationPreference
errors = []
params[:notification_preferences].each do |notification_preference|
notification_type = NotificationType.find_by(name: notification_preference[:notification_type])
db_notification_preference = NotificationPreference.find_or_create_by(notification_type_id: notification_type.id, user: current_user)
next if db_notification_preference.update(email: notification_preference[:email], in_system: notification_preference[:in_system])
errors.push(db_notification_preference.errors)
end
if errors.any?
render json: errors, status: :unprocessable_entity
else
head :no_content, status: :ok
end
end
private
def notification_preference_params
params.require(:notification_preference).permit(:notification_type_id, :in_system, :email)
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# API Controller for resources of type Notification Types
class API::NotificationTypesController < API::ApiController
before_action :authenticate_user!
def index
@notification_types = if params[:is_configurable] == 'true'
NotificationType.where(is_configurable: true)
else
NotificationType.all
end
end
end

View File

@ -0,0 +1,20 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { NotificationPreference } from '../models/notification-preference';
export default class NotificationPreferencesAPI {
static async index (): Promise<Array<NotificationPreference>> {
const res: AxiosResponse<Array<NotificationPreference>> = await apiClient.get('/api/notification_preferences');
return res?.data;
}
static async update (updatedPreference: NotificationPreference): Promise<NotificationPreference> {
const res: AxiosResponse<NotificationPreference> = await apiClient.patch(`/api/notification_preferences/${updatedPreference.notification_type}`, { notification_preference: updatedPreference });
return res?.data;
}
static async bulk_update (updatedPreferences: Array<NotificationPreference>): Promise<NotificationPreference> {
const res: AxiosResponse<NotificationPreference> = await apiClient.patch('/api/notification_preferences/bulk_update', { notification_preferences: updatedPreferences });
return res?.data;
}
}

View File

@ -0,0 +1,11 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { NotificationTypeIndexFilter, NotificationType } from '../models/notification-type';
import ApiLib from '../lib/api';
export default class NotificationTypesAPI {
static async index (isConfigurable?:NotificationTypeIndexFilter): Promise<Array<NotificationType>> {
const res: AxiosResponse<Array<NotificationType>> = await apiClient.get(`/api/notification_types${ApiLib.filtersToQuery(isConfigurable)}`);
return res?.data;
}
}

View File

@ -0,0 +1,65 @@
import { useEffect } from 'react';
import { Loader } from '../base/loader';
import { useTranslation } from 'react-i18next';
import { NotificationPreference } from '../../models/notification-preference';
import { useForm } from 'react-hook-form';
import { FormSwitch } from '../form/form-switch';
import NotificationPreferencesAPI from '../../api/notification_preference';
interface NotificationFormProps {
onError: (message: string) => void,
preference: NotificationPreference
}
/**
* Displays the list of notifications
*/
const NotificationForm: React.FC<NotificationFormProps> = ({ preference, onError }) => {
const { t } = useTranslation('logged');
const { handleSubmit, formState, control, reset } = useForm<NotificationPreference>({ defaultValues: { ...preference } });
// Create or Update (if id exists) a Notification Preference
const onSubmit = (updatedPreference: NotificationPreference) => NotificationPreferencesAPI.update(updatedPreference).catch(onError);
// Calls submit handler on every change of a Form Switch
const handleChange = () => handleSubmit(onSubmit)();
// Resets form on component mount, and if preference changes (happens when bulk updating a category)
useEffect(() => {
reset(preference);
}, [preference]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="notification-form">
<p className="notification-type">{t(`app.logged.notification_form.${preference.notification_type}`)}</p>
<div className='form-actions'>
<FormSwitch
className="form-action"
control={control}
formState={formState}
defaultValue={preference.email}
id="email"
label='email'
onChange={handleChange}/>
<FormSwitch
className="form-action"
control={control}
formState={formState}
defaultValue={preference.in_system}
id="in_system"
label='push'
onChange={handleChange}/>
</div>
</form>
);
};
const NotificationFormWrapper: React.FC<NotificationFormProps> = (props) => {
return (
<Loader>
<NotificationForm {...props} />
</Loader>
);
};
export { NotificationFormWrapper as NotificationForm };

View File

@ -0,0 +1,58 @@
import { Loader } from '../base/loader';
import { useTranslation } from 'react-i18next';
import { NotificationPreference } from '../../models/notification-preference';
import { NotificationForm } from './notification-form';
import { FabButton } from '../base/fab-button';
import NotificationPreferencesAPI from '../../api/notification_preference';
interface NotificationsCategoryProps {
onError: (message: string) => void,
refreshSettings: () => void,
categoryName: string,
preferences: Array<NotificationPreference>
}
/**
* Displays the list of notifications
*/
const NotificationsCategory: React.FC<NotificationsCategoryProps> = ({ onError, categoryName, preferences, refreshSettings }) => {
const { t } = useTranslation('logged');
// Triggers a general update to enable all notifications for this category
const enableAll = () => updateAll(true);
// Triggers a general update to disable all notifications for this category
const disableAll = () => updateAll(false);
// Update all notifications for this category with a bulk_update.
// This triggers a refresh of all the forms.
const updateAll = async (value: boolean) => {
const updatedPreferences: Array<NotificationPreference> = preferences.map(preference => {
return { id: preference.id, notification_type: preference.notification_type, in_system: value, email: value };
});
await NotificationPreferencesAPI.bulk_update(updatedPreferences).catch(onError);
refreshSettings();
};
return (
<div className="notifications-category">
<h2 className="category-name">{`${t(`app.logged.notifications_category.${categoryName}`)}, ${t('app.logged.notifications_category.notify_me_when')}`}</h2>
<div className="category-content">
<div className="category-actions">
<FabButton className="category-action category-action-left" onClick={enableAll}>{t('app.logged.notifications_category.enable_all')}</FabButton>
<FabButton className="category-action" onClick={disableAll}>{t('app.logged.notifications_category.disable_all')}</FabButton>
</div>
{preferences.map(preference => <NotificationForm key={preference.notification_type} preference={preference} onError={onError}/>)}
</div>
</div>
);
};
const NotificationsCategoryWrapper: React.FC<NotificationsCategoryProps> = (props) => {
return (
<Loader>
<NotificationsCategory {...props} />
</Loader>
);
};
export { NotificationsCategoryWrapper as NotificationsCategory };

View File

@ -4,8 +4,10 @@ import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { FabTabs } from '../base/fab-tabs';
import { NotificationsList } from './notifications-list';
import { NotificationsSettings } from './notifications-settings';
import { useTranslation } from 'react-i18next';
import MemberAPI from '../../api/member';
import { UserRole } from '../../models/user';
declare const Application: IApplication;
@ -17,31 +19,30 @@ interface NotificationsCenterProps {
* This Admin component groups two tabs : a list of notifications and the notifications settings
*/
export const NotificationsCenter: React.FC<NotificationsCenterProps> = ({ onError }) => {
const { t } = useTranslation('admin');
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const { t } = useTranslation('logged');
const [role, setRole] = useState<UserRole>();
useEffect(() => {
MemberAPI.current()
.then(data => {
if (data.role === 'admin') setIsAdmin(true);
});
.then(data => setRole(data.role))
.catch(onError);
}, []);
return (
<>
{isAdmin && <FabTabs defaultTab='notifications-list' tabs={[
{role === 'admin' && <FabTabs defaultTab='notifications_settings' tabs={[
{
id: 'notifications_settings',
title: t('app.admin.notifications_center.notifications_settings'),
content: 'to do notifications_settings'
title: t('app.logged.notifications_center.notifications_settings'),
content: <NotificationsSettings onError={onError}/>
},
{
id: 'notifications-list',
title: t('app.admin.notifications_center.notifications_list'),
title: t('app.logged.notifications_center.notifications_list'),
content: <NotificationsList onError={onError}/>
}
]} />}
{!isAdmin && <NotificationsList onError={onError}/>}
{role !== 'admin' && <NotificationsList onError={onError}/>}
</>
);
};

View File

@ -0,0 +1,86 @@
import { Loader } from '../base/loader';
import { useEffect, useState } from 'react';
import NotificationPreferencesAPI from '../../api/notification_preference';
import { NotificationPreference, NotificationCategoryNames, NotificationPreferencesByCategories } from '../../models/notification-preference';
import { NotificationsCategory } from './notifications-category';
import NotificationTypesAPI from '../../api/notification_types';
interface NotificationsSettingsProps {
onError: (message: string) => void
}
/**
* Displays the list of notifications
*/
const NotificationsSettings: React.FC<NotificationsSettingsProps> = ({ onError }) => {
const [preferencesByCategories, setPreferencesCategories] = useState<NotificationPreferencesByCategories>({});
// From a default pattern of categories, and existing preferences and types retrieved from API,
// this function builds an object with Notification Preferences sorted by categories.
const fetchNotificationPreferences = async () => {
let notificationPreferences: Array<NotificationPreference>;
await NotificationPreferencesAPI.index()
.then(userNotificationPreferences => {
notificationPreferences = userNotificationPreferences;
})
.catch(onError);
NotificationTypesAPI.index({ is_configurable: true })
.then(notificationTypes => {
// Initialize an object with every categories as keys
const newPreferencesByCategories: NotificationPreferencesByCategories = {};
for (const categoryName of NotificationCategoryNames) {
newPreferencesByCategories[categoryName] = [];
}
// For every notification type, we check if a notification preference already exists.
// If there is none, we create one with default values.
// Each Notification Preference is then placed in the right category.
notificationTypes.forEach((notificationType) => {
const existingPreference = notificationPreferences.find((notificationPreference) => {
return notificationPreference.notification_type === notificationType.name;
});
newPreferencesByCategories[notificationType.category].push(
existingPreference ||
{
notification_type: notificationType.name,
in_system: true,
email: true
}
);
});
setPreferencesCategories(newPreferencesByCategories);
})
.catch(onError);
};
// Triggers the fetch Notification Preferences on component mount
useEffect(() => {
fetchNotificationPreferences();
}, []);
return (
<div className="notifications-settings">
{Object.entries(preferencesByCategories).map((notificationPreferencesCategory) => (
<NotificationsCategory
key={notificationPreferencesCategory[0]}
categoryName={notificationPreferencesCategory[0]}
preferences={notificationPreferencesCategory[1]}
onError={onError}
refreshSettings={fetchNotificationPreferences} />
))
}
</div>
);
};
const NotificationsSettingsWrapper: React.FC<NotificationsSettingsProps> = (props) => {
return (
<Loader>
<NotificationsSettings {...props} />
</Loader>
);
};
export { NotificationsSettingsWrapper as NotificationsSettings };

View File

@ -0,0 +1,24 @@
export interface NotificationPreference {
id: number,
notification_type: string,
email: boolean,
in_system: boolean
}
// This controls the order of the categories' display in the notification center
export const NotificationCategoryNames = [
'users_accounts',
'proof_of_identity',
'agenda',
'subscriptions',
'payments',
'wallet',
'shop',
'projects',
'accountings',
'trainings'
] as const;
export type NotificationCategoryName = typeof NotificationCategoryNames[number];
export type NotificationPreferencesByCategories = Record<NotificationCategoryName, Array<NotificationPreference>> | Record<never, never>

View File

@ -75,7 +75,9 @@
@import "modules/machines/machines-settings";
@import "modules/machines/required-training-modal";
@import "modules/notifications/notifications-list";
@import "modules/notifications/notification-line";
@import "modules/notifications/notification-inline";
@import "modules/notifications/notifications-category";
@import "modules/notifications/notification-form";
@import "modules/payment-schedule/payment-schedule-dashboard";
@import "modules/payment-schedule/payment-schedule-summary";
@import "modules/payment-schedule/payment-schedules-list";

View File

@ -0,0 +1,32 @@
.notification-form {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 2rem;
border-bottom: 1px solid var(--gray-soft-dark);
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
.notification-type {
flex-grow: 1;
}
.form-actions {
display: flex;
gap: 20px;
flex-shrink: 0;
}
.form-action {
width: 144px;
}
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
}

View File

@ -0,0 +1,52 @@
.notifications-category {
max-width: 1200px;
display: flex;
justify-content: space-between;
gap: 32px;
.category-name {
width: 288px;
flex-shrink: 0;
color: var(--gray-hard-darkest) !important;
@include title-base;
}
.category-content {
max-width: 800px;
flex-grow: 1;
margin-top: 24px;
padding: 20px;
background-color: var(--gray-soft-light);
border: 1px solid var(--gray-soft-dark);
border-radius: 5px;
}
.category-actions {
display: flex;
justify-content: end;
gap: 6px;
}
.category-action {
background-color: #f5f5f5;
font-size: 15px;
text-transform: uppercase;
border: none;
margin-bottom: 3rem;
&:hover {
background-color: var(--gray-soft-dark);
}
}
@media (max-width: 1200px) {
flex-direction: column;
gap: 0px;
.category-name {
width: fit-content;
}
.category-content {
margin-bottom: 32px;
}
}
}

View File

@ -4,4 +4,10 @@
class NotificationPreference < ApplicationRecord
belongs_to :user
belongs_to :notification_type
validates :notification_type_id, uniqueness: { scope: :user }
def notification_type
NotificationType.find(notification_type_id).name
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Check the access policies for API::NotificationController
class NotificationPreferencePolicy < ApplicationPolicy
def update?
user.admin?
end
def bulk_update?
user.admin?
end
end

View File

@ -0,0 +1,3 @@
json.array!(@notification_preferences) do |notification_preference|
json.extract! notification_preference, :id, :notification_type, :in_system, :email
end

View File

@ -0,0 +1 @@
json.extract! @notification_preference, :id, :notification_type, :in_system, :email

View File

@ -0,0 +1,3 @@
json.array!(@notification_types) do |notification_type|
json.extract! notification_type, :id, :name, :category, :is_configurable
end

View File

@ -2412,6 +2412,3 @@ en:
cta_switch: "Display a button"
cta_label: "Button label"
cta_url: "Button link"
notifications_center:
notifications_list: "All notifications"
notifications_settings: "My notifications preferences"

View File

@ -260,5 +260,63 @@ en:
archives: "Archives"
no_archived_notifications: "No archived notifications."
load_the_next_notifications: "Load the next notifications..."
notification_line:
notification_inline:
mark_as_read: "Mark as read"
notifications_center:
notifications_list: "All notifications"
notifications_settings: "My notifications preferences"
notifications_category:
enable_all: "Enable all"
disable_all: "Disable all"
notify_me_when: "I wish to be notified when"
users_accounts: "Concerning users notifications"
proof_of_identity: "Concerning identity proofs notifications"
agenda: "Concerning agenda notifications"
subscriptions: "Concerning subscriptions notifications"
payments: "Concerning payment schedules notifications"
wallet: "Concerning wallet notifications"
shop: "Concerning shop notifications"
projects: "Concerning projects notifications"
accountings: "Concerning accounting notifications"
trainings: "Concerning trainings notifications"
app_management: "Concerning app management notifications"
notification_form:
notify_admin_when_user_is_created: "A user account has been created"
notify_admin_when_user_is_imported: "A user account has been imported"
notify_admin_profile_complete: "An imported account has completed its profile"
notify_admin_user_merged: "An imported account has been merged with an existing account"
notify_admins_role_update: "The role of a user has changed"
notify_admin_import_complete: "An import is done"
notify_admin_user_group_changed: "A group has changed"
notify_admin_user_proof_of_identity_refusal: "A proof of identity has been rejected"
notify_admin_user_proof_of_identity_files_created: "A user has uploaded a proof of identity"
notify_admin_user_proof_of_identity_files_updated: "A user has updated a proof of identity"
notify_admin_member_create_reservation: "A member creates a reservation"
notify_admin_slot_is_modified: "A reservation slot has been modified"
notify_admin_slot_is_canceled: "A reservation has been cancelled"
notify_admin_subscribed_plan: "A subscription has been purchased"
notify_admin_subscription_will_expire_in_7_days: "A member subscription expires in 7 days"
notify_admin_subscription_is_expired: "A member subscription has expired"
notify_admin_subscription_extended: "A subscription has been extended"
notify_admin_subscription_canceled: "A member subscription has been cancelled"
notify_admin_payment_schedule_failed: "Card debit failure"
notify_admin_payment_schedule_check_deadline: "A payment deadline is soon"
notify_admin_payment_schedule_transfer_deadline: "A bank direct debit has to be confirmed"
notify_admin_payment_schedule_error: "An error occurred for the card debit for a schedule"
notify_admin_gateway_canceled: "You must confirm a bank direct debit for for a schedule"
notify_admin_refund_created: "A refund has been created"
notify_admin_user_wallet_is_credited: "The wallet of an user has been credited"
notify_user_order_is_ready: "Your command is ready"
notify_user_order_is_canceled: "Your command was canceled"
notify_user_order_is_refunded: "Your command was refunded"
notify_admin_low_stock_threshold: "The stock is low"
notify_admin_when_project_published: "A project has been published"
notify_admin_abuse_reported: "An abusive content has been reported"
notify_admin_close_period_reminder: "An accounting period has to be closed soon"
notify_admin_archive_complete: "An archive is completed"
notify_admin_training_auto_cancelled: "A training was automatically cancelled"
notify_admin_export_complete: "An export is completed"
notify_user_when_invoice_ready: "An invoice is available"
notify_admin_payment_schedule_gateway_canceled: "A payment schedule has been canceled by the payment gateway"
notify_project_collaborator_to_valid: "You are invited to collaborate on the project"
notify_project_author_when_collaborator_valid: "A collaborator has accepted your invitation to join your project"

View File

@ -72,6 +72,10 @@ Rails.application.routes.draw do
get 'polling', action: 'polling', on: :collection
get 'last_unread', action: 'last_unread', on: :collection
end
resources :notification_types, only: %i[index]
resources :notification_preferences, only: %i[index update], param: :notification_type do
patch '/bulk_update', action: 'bulk_update', on: :collection
end
resources :wallet, only: [] do
get '/by_user/:user_id', action: 'by_user', on: :collection
get :transactions, on: :member

View File

@ -5,11 +5,13 @@
class CreateNotificationPreferences < ActiveRecord::Migration[5.2]
def change
create_table :notification_preferences do |t|
t.references :user, index: true, foreign_key: true, null: false
t.references :user, index: false, foreign_key: true, null: false
t.references :notification_type, index: true, foreign_key: true, null: false
t.boolean :in_system, default: true
t.boolean :email, default: true
t.index %i[user_id notification_type_id], unique: true, name: :index_notification_preferences_on_user_and_notification_type
t.timestamps
end
end