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
+61
View File
@@ -21,6 +21,7 @@ import {
UpdateLanguageBody,
UpdateClockStyleBody,
UpdateClockHour12Body,
UpdateNotificationPreferencesBody,
ForgotPasswordBody,
ResetPasswordBody,
VerifyResetTokenBody,
@@ -61,6 +62,13 @@ function buildAuthUser(
clockHour12: user.clockHour12,
avatarUrl: user.avatarUrl,
isActive: user.isActive,
notificationSoundOrder: user.notificationSoundOrder,
notificationSoundMeeting: user.notificationSoundMeeting,
notifyOrdersEnabled: user.notifyOrdersEnabled,
notifyMeetingsEnabled: user.notifyMeetingsEnabled,
vibrationEnabled: user.vibrationEnabled,
notificationsMuted: user.notificationsMuted,
notificationVolume: user.notificationVolume,
roles,
groups,
createdAt: user.createdAt,
@@ -417,4 +425,57 @@ router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise<void
res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});
router.patch("/auth/me/notification-preferences", requireAuth, async (req, res): Promise<void> => {
const parsed = UpdateNotificationPreferencesBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
if (
parsed.data.notificationVolume !== undefined &&
!Number.isInteger(parsed.data.notificationVolume)
) {
res.status(400).json({ error: "notificationVolume must be an integer" });
return;
}
const updates: Record<string, unknown> = {};
for (const k of [
"notificationSoundOrder",
"notificationSoundMeeting",
"notifyOrdersEnabled",
"notifyMeetingsEnabled",
"vibrationEnabled",
"notificationsMuted",
"notificationVolume",
] as const) {
if (parsed.data[k] !== undefined) updates[k] = parsed.data[k];
}
if (Object.keys(updates).length === 0) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, req.session.userId!));
if (!user) {
res.status(401).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
return;
}
const [user] = await db
.update(usersTable)
.set(updates)
.where(eq(usersTable.id, req.session.userId!))
.returning();
if (!user) {
res.status(401).json({ error: "User not found" });
return;
}
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});
export default router;