Task #389: Notification sounds + vibration with per-user customization

- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume).
- API: PATCH /auth/me/notification-preferences with whitelisted partial update,
  integer guard for volume, and hydrated AuthUser response. AuthUser now exposes
  the 7 new fields.
- Frontend: Web Audio synth library (8 sounds), settings popover with global
  mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons,
  shift+click on bell for quick mute. Concurrency-safe optimistic updates with
  monotonic seq counter. RTL-correct toggle knob transforms.
- Socket hook plays sound on notification_created (orders/meetings only) when
  tab is hidden, respecting global mute and per-type toggles.
- Audio unlock hook mounted globally to satisfy autoplay restrictions.
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing
test failures in test workflow are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 08:13:38 +00:00
parent 477bcbcce9
commit 242f63ab26
14 changed files with 1042 additions and 1 deletions
@@ -96,6 +96,34 @@ export interface UpdateClockStyleBody {
clockStyle: ClockStyle | null;
}
export type NotificationSoundId =
(typeof NotificationSoundId)[keyof typeof NotificationSoundId];
export const NotificationSoundId = {
ding: "ding",
chime: "chime",
bell: "bell",
knock: "knock",
pop: "pop",
alert: "alert",
beep: "beep",
soft: "soft",
} as const;
export interface UpdateNotificationPreferencesBody {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
notificationsMuted?: boolean;
/**
* @minimum 0
* @maximum 100
*/
notificationVolume?: number;
}
export interface UpdateClockHour12Body {
/**
* Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour)
@@ -128,6 +156,13 @@ export interface AuthUser {
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
notificationSoundOrder: NotificationSoundId;
notificationSoundMeeting: NotificationSoundId;
notifyOrdersEnabled: boolean;
notifyMeetingsEnabled: boolean;
vibrationEnabled: boolean;
notificationsMuted: boolean;
notificationVolume: number;
roles: string[];
groups: GroupSummary[];
createdAt: string;
+89
View File
@@ -108,6 +108,7 @@ import type {
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateNotificationPreferencesBody,
UpdateRoleBody,
UpdateServiceBody,
UpdateServiceOrderStatusBody,
@@ -1036,6 +1037,94 @@ export const useUpdateClockStyle = <
return useMutation(getUpdateClockStyleMutationOptions(options));
};
/**
* @summary Update notification sound, vibration & mute preferences
*/
export const getUpdateNotificationPreferencesUrl = () => {
return `/api/auth/me/notification-preferences`;
};
export const updateNotificationPreferences = async (
updateNotificationPreferencesBody: UpdateNotificationPreferencesBody,
options?: RequestInit,
): Promise<AuthUser> => {
return customFetch<AuthUser>(getUpdateNotificationPreferencesUrl(), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateNotificationPreferencesBody),
});
};
export const getUpdateNotificationPreferencesMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
> => {
const mutationKey = ["updateNotificationPreferences"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
{ data: BodyType<UpdateNotificationPreferencesBody> }
> = (props) => {
const { data } = props ?? {};
return updateNotificationPreferences(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNotificationPreferencesMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNotificationPreferences>>
>;
export type UpdateNotificationPreferencesMutationBody =
BodyType<UpdateNotificationPreferencesBody>;
export type UpdateNotificationPreferencesMutationError =
ErrorType<ErrorResponse>;
/**
* @summary Update notification sound, vibration & mute preferences
*/
export const useUpdateNotificationPreferences = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
> => {
return useMutation(getUpdateNotificationPreferencesMutationOptions(options));
};
/**
* @summary Update preferred 12/24-hour clock format
*/