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
+8 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, serial, timestamp, varchar, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, serial, timestamp, varchar, boolean, integer } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
@@ -14,6 +14,13 @@ export const usersTable = pgTable("users", {
clockHour12: boolean("clock_hour12"),
avatarUrl: text("avatar_url"),
isActive: boolean("is_active").notNull().default(true),
notificationSoundOrder: varchar("notification_sound_order", { length: 30 }).notNull().default("ding"),
notificationSoundMeeting: varchar("notification_sound_meeting", { length: 30 }).notNull().default("chime"),
notifyOrdersEnabled: boolean("notify_orders_enabled").notNull().default(true),
notifyMeetingsEnabled: boolean("notify_meetings_enabled").notNull().default(true),
vibrationEnabled: boolean("vibration_enabled").notNull().default(true),
notificationsMuted: boolean("notifications_muted").notNull().default(false),
notificationVolume: integer("notification_volume").notNull().default(70),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});