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
+204
View File
@@ -54,6 +54,31 @@ export const LoginResponse = zod.object({
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
@@ -142,6 +167,31 @@ export const GetMeResponse = zod.object({
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
@@ -179,6 +229,31 @@ export const UpdateLanguageResponse = zod.object({
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
@@ -223,6 +298,110 @@ export const UpdateClockStyleResponse = zod.object({
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
}),
),
createdAt: zod.coerce.date(),
});
/**
* @summary Update notification sound, vibration & mute preferences
*/
export const updateNotificationPreferencesBodyNotificationVolumeMin = 0;
export const updateNotificationPreferencesBodyNotificationVolumeMax = 100;
export const UpdateNotificationPreferencesBody = zod.object({
notificationSoundOrder: zod
.enum(["ding", "chime", "bell", "knock", "pop", "alert", "beep", "soft"])
.optional(),
notificationSoundMeeting: zod
.enum(["ding", "chime", "bell", "knock", "pop", "alert", "beep", "soft"])
.optional(),
notifyOrdersEnabled: zod.boolean().optional(),
notifyMeetingsEnabled: zod.boolean().optional(),
vibrationEnabled: zod.boolean().optional(),
notificationsMuted: zod.boolean().optional(),
notificationVolume: zod
.number()
.min(updateNotificationPreferencesBodyNotificationVolumeMin)
.max(updateNotificationPreferencesBodyNotificationVolumeMax)
.optional(),
});
export const UpdateNotificationPreferencesResponse = zod.object({
id: zod.number(),
username: zod.string(),
email: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
preferredLanguage: zod.string(),
clockStyle: zod
.union([
zod
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
.describe("Per-user home-screen clock style preference"),
zod.null(),
])
.optional(),
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({
@@ -265,6 +444,31 @@ export const UpdateClockHour12Response = zod.object({
clockHour12: zod.boolean().nullish(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
notificationSoundOrder: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notificationSoundMeeting: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
groups: zod.array(
zod.object({