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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user