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

DB
- Added 7 user pref columns; vibration is now per-channel
  (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
  sounds, vibration on, mute off, volume 70.

API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
  update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.

Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
  (notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
  cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute, volume, slot tabs (orders/meetings) with
  per-channel sound + per-channel vibration + per-channel toggle, sound list
  with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
  counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
  confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
  the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
  notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
  meeting (deduped by meetingId, survives 30s polling refetches).
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
This commit is contained in:
riyadhafraa
2026-05-05 08:20:54 +00:00
parent 242f63ab26
commit 4af3917026
23 changed files with 292 additions and 189 deletions
+4 -2
View File
@@ -66,7 +66,8 @@ function buildAuthUser(
notificationSoundMeeting: user.notificationSoundMeeting,
notifyOrdersEnabled: user.notifyOrdersEnabled,
notifyMeetingsEnabled: user.notifyMeetingsEnabled,
vibrationEnabled: user.vibrationEnabled,
vibrationEnabledOrder: user.vibrationEnabledOrder,
vibrationEnabledMeeting: user.vibrationEnabledMeeting,
notificationsMuted: user.notificationsMuted,
notificationVolume: user.notificationVolume,
roles,
@@ -445,7 +446,8 @@ router.patch("/auth/me/notification-preferences", requireAuth, async (req, res):
"notificationSoundMeeting",
"notifyOrdersEnabled",
"notifyMeetingsEnabled",
"vibrationEnabled",
"vibrationEnabledOrder",
"vibrationEnabledMeeting",
"notificationsMuted",
"notificationVolume",
] as const) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -5,6 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/contexts/AuthContext";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
@@ -33,6 +34,7 @@ const queryClient = new QueryClient({
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
useAutoplayHint();
return null;
}
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
import { notificationPlayer } from "@/lib/notification-sounds";
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
const ALERT_LEAD_MINUTES = 5;
@@ -457,6 +458,22 @@ export function UpcomingMeetingAlert() {
setExpanded(false);
}, [eligibleId]);
// Play the meeting reminder sound exactly once per *new* eligible
// meeting (deduped by id) so polling refetches don't replay the chime
// every 30s. Honors the user's mute / per-channel toggle.
const playedRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!eligibleId || !user) return;
if (playedRef.current.has(eligibleId)) return;
playedRef.current.add(eligibleId);
if (user.notificationsMuted) return;
if (!user.notifyMeetingsEnabled) return;
notificationPlayer.play(user.notificationSoundMeeting, {
volume: user.notificationVolume,
vibrate: user.vibrationEnabledMeeting,
});
}, [eligibleId, user]);
if (!enabled || !eligible) return null;
// User toggled the popup off in settings — render nothing. Detection
// happens after `enabled/eligible` so the upstream "shown" recording
@@ -7,7 +7,8 @@ import {
type AuthUser,
type NotificationSoundId,
} from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, Play } from "lucide-react";
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Popover,
PopoverContent,
@@ -21,7 +22,8 @@ type PrefsPatch = {
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
vibrationEnabledOrder?: boolean;
vibrationEnabledMeeting?: boolean;
notificationsMuted?: boolean;
notificationVolume?: number;
};
@@ -157,6 +159,42 @@ function Toggle({
);
}
/**
* One-tap mute toggle for the topbar (separate from the settings popover
* trigger). Shows a confirmation toast so the user knows the change took
* effect even though there's no visual focus shift.
*/
export function QuickMuteButton() {
const { t } = useTranslation();
const { user } = useAuth();
const { toast } = useToast();
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
return (
<button
type="button"
aria-label={t(muted ? "notifSettings.quickUnmute" : "notifSettings.quickMute")}
aria-pressed={muted}
data-testid="notif-quick-mute"
onClick={() => {
const next = !muted;
apply({ notificationsMuted: next });
toast({
title: t(next ? "notifSettings.mutedToast" : "notifSettings.unmutedToast"),
});
}}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5"
>
{muted ? (
<VolumeX className="w-[18px] h-[18px] md:w-6 md:h-6" />
) : (
<Volume2 className="w-[18px] h-[18px] md:w-6 md:h-6" />
)}
</button>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
@@ -173,16 +211,9 @@ export function NotificationSettingsPicker() {
<PopoverTrigger asChild>
<button
type="button"
aria-label={t(muted ? "notifSettings.unmute" : "notifSettings.mute")}
aria-pressed={muted}
aria-label={t("notifSettings.title")}
aria-haspopup="dialog"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
onClick={(e) => {
// Shift+click toggles mute quickly without opening the popover.
if (e.shiftKey) {
e.preventDefault();
apply({ notificationsMuted: !muted });
}
}}
>
{muted ? (
<BellOff className="w-[18px] h-[18px] md:w-6 md:h-6" />
@@ -201,11 +232,6 @@ export function NotificationSettingsPicker() {
onClick={() => apply({ notificationsMuted: !muted })}
label={t("notifSettings.globalSound")}
/>
<Toggle
active={user.vibrationEnabled}
onClick={() => apply({ vibrationEnabled: !user.vibrationEnabled })}
label={t("notifSettings.vibration")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
@@ -230,23 +256,6 @@ export function NotificationSettingsPicker() {
<div className="h-px bg-foreground/10 mx-1 my-1" />
<Toggle
active={user.notifyOrdersEnabled}
onClick={() =>
apply({ notifyOrdersEnabled: !user.notifyOrdersEnabled })
}
label={t("notifSettings.ordersEnabled")}
/>
<Toggle
active={user.notifyMeetingsEnabled}
onClick={() =>
apply({ notifyMeetingsEnabled: !user.notifyMeetingsEnabled })
}
label={t("notifSettings.meetingsEnabled")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
@@ -271,6 +280,47 @@ export function NotificationSettingsPicker() {
})}
</div>
<Toggle
active={
slot === "order"
? user.notifyOrdersEnabled
: user.notifyMeetingsEnabled
}
onClick={() =>
apply(
slot === "order"
? { notifyOrdersEnabled: !user.notifyOrdersEnabled }
: { notifyMeetingsEnabled: !user.notifyMeetingsEnabled },
)
}
label={t(
slot === "order"
? "notifSettings.ordersEnabled"
: "notifSettings.meetingsEnabled",
)}
/>
<Toggle
active={
slot === "order"
? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting
}
onClick={() =>
apply(
slot === "order"
? { vibrationEnabledOrder: !user.vibrationEnabledOrder }
: { vibrationEnabledMeeting: !user.vibrationEnabledMeeting },
)
}
label={t(
slot === "order"
? "notifSettings.vibrationOrder"
: "notifSettings.vibrationMeeting",
)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={
slot === "order"
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/hooks/use-toast";
import { AUTOPLAY_BLOCKED_EVENT } from "@/lib/notification-sounds";
/**
* When a notification sound is attempted before the browser has
* accepted a user gesture, the player dispatches a custom event. We
* surface a one-time toast asking the user to click anywhere so future
* sounds can play. The first qualifying click also triggers
* `useAudioUnlock`, so the hint typically only ever shows once per
* page load.
*/
export function useAutoplayHint(): void {
const { t } = useTranslation();
const { toast } = useToast();
useEffect(() => {
const handler = () => {
toast({
title: t("notifSettings.autoplayHintTitle"),
description: t("notifSettings.autoplayHint"),
});
};
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
return () => window.removeEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
}, [t, toast]);
}
@@ -56,9 +56,12 @@ export function useNotificationsSocket() {
const soundId = isOrder
? user.notificationSoundOrder
: user.notificationSoundMeeting;
const vibrate = isOrder
? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting;
notificationPlayer.play(soundId, {
volume: user.notificationVolume,
vibrate: user.vibrationEnabled,
vibrate,
});
});
@@ -0,0 +1,31 @@
import type { NotificationSoundId } from "@workspace/api-client-react";
const BASE = (import.meta.env.BASE_URL || "/").replace(/\/$/, "");
export type NotificationSoundEntry = {
id: NotificationSoundId;
labelKey: string;
url: string;
};
/**
* Manifest of bundled short notification sounds. The actual audio files
* live under `public/sounds/<id>.wav` in the tx-os artifact and are
* served from the artifact base path. The matching i18n labels live in
* `notifSettings.sounds.<id>` for both languages.
*/
export const NOTIFICATION_SOUND_MANIFEST: ReadonlyArray<NotificationSoundEntry> =
[
{ id: "ding", labelKey: "notifSettings.sounds.ding", url: `${BASE}/sounds/ding.wav` },
{ id: "chime", labelKey: "notifSettings.sounds.chime", url: `${BASE}/sounds/chime.wav` },
{ id: "bell", labelKey: "notifSettings.sounds.bell", url: `${BASE}/sounds/bell.wav` },
{ id: "knock", labelKey: "notifSettings.sounds.knock", url: `${BASE}/sounds/knock.wav` },
{ id: "pop", labelKey: "notifSettings.sounds.pop", url: `${BASE}/sounds/pop.wav` },
{ id: "alert", labelKey: "notifSettings.sounds.alert", url: `${BASE}/sounds/alert.wav` },
{ id: "beep", labelKey: "notifSettings.sounds.beep", url: `${BASE}/sounds/beep.wav` },
{ id: "soft", labelKey: "notifSettings.sounds.soft", url: `${BASE}/sounds/soft.wav` },
];
export const SOUND_BY_ID = new Map(
NOTIFICATION_SOUND_MANIFEST.map((s) => [s.id, s]),
);
+75 -137
View File
@@ -1,128 +1,72 @@
import type { NotificationSoundId } from "@workspace/api-client-react";
import {
NOTIFICATION_SOUND_MANIFEST,
SOUND_BY_ID,
} from "@/lib/notification-sound-manifest";
type ToneStep = {
freq: number;
duration: number;
delay?: number;
type?: OscillatorType;
gain?: number;
};
export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
NOTIFICATION_SOUND_MANIFEST.map((s) => s.id);
type SoundDef = {
id: NotificationSoundId;
steps: ToneStep[];
};
export const SOUND_LIBRARY: ReadonlyArray<SoundDef> = [
{
id: "ding",
steps: [
{ freq: 1318.5, duration: 0.35, type: "sine", gain: 0.5 },
{ freq: 880, duration: 0.45, delay: 0.05, type: "sine", gain: 0.35 },
],
},
{
id: "chime",
steps: [
{ freq: 523.25, duration: 0.3, type: "sine" },
{ freq: 659.25, duration: 0.3, delay: 0.12, type: "sine" },
{ freq: 783.99, duration: 0.5, delay: 0.24, type: "sine" },
],
},
{
id: "bell",
steps: [
{ freq: 1760, duration: 0.6, type: "triangle", gain: 0.45 },
{ freq: 880, duration: 0.6, delay: 0.0, type: "sine", gain: 0.25 },
],
},
{
id: "knock",
steps: [
{ freq: 180, duration: 0.12, type: "square", gain: 0.55 },
{ freq: 180, duration: 0.12, delay: 0.18, type: "square", gain: 0.55 },
],
},
{
id: "pop",
steps: [
{ freq: 600, duration: 0.08, type: "sine", gain: 0.55 },
{ freq: 1200, duration: 0.12, delay: 0.04, type: "sine", gain: 0.45 },
],
},
{
id: "alert",
steps: [
{ freq: 988, duration: 0.18, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.22, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.44, type: "square", gain: 0.4 },
],
},
{
id: "beep",
steps: [{ freq: 1000, duration: 0.25, type: "sine", gain: 0.5 }],
},
{
id: "soft",
steps: [
{ freq: 392, duration: 0.5, type: "sine", gain: 0.35 },
{ freq: 587.33, duration: 0.5, delay: 0.1, type: "sine", gain: 0.3 },
],
},
];
const SOUND_BY_ID = new Map(SOUND_LIBRARY.map((s) => [s.id, s]));
const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> = SOUND_LIBRARY.map(
(s) => s.id,
);
export { ALL_SOUND_IDS };
type AnyAudioContext = AudioContext;
/**
* Custom DOM event the player dispatches when a play attempt is denied
* because the user hasn't yet interacted with the page (browser autoplay
* policy). The App listens for this and shows a one-time toast hint.
*/
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
class NotificationPlayer {
private ctx: AnyAudioContext | null = null;
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
private unlocked = false;
private lastPlayAt = 0;
private hintDispatched = false;
private getCtx(): AnyAudioContext | null {
if (typeof window === "undefined") return null;
if (this.ctx) return this.ctx;
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return null;
try {
this.ctx = new Ctor();
} catch {
this.ctx = null;
}
return this.ctx;
isUnlocked(): boolean {
return this.unlocked;
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the AudioContext.
* the user interacts with the app to "unlock" the audio elements.
*/
unlock(): void {
if (this.unlocked) return;
const ctx = this.getCtx();
if (!ctx) return;
if (ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
// Play a tiny silent buffer to satisfy iOS/Safari unlock semantics.
try {
const buffer = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.start(0);
} catch {
/* ignore */
}
this.unlocked = true;
// Touch each cached element with a silent play()/pause() so iOS/Safari
// marks them as user-initiated. Pre-warm cache for snappier playback.
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
}
el.muted = true;
const p = el.play();
if (p && typeof p.then === "function") {
p.then(() => {
el!.pause();
el!.currentTime = 0;
el!.muted = false;
}).catch(() => {
el!.muted = false;
});
} else {
el.muted = false;
}
}
}
private dispatchHint() {
if (this.hintDispatched) return;
this.hintDispatched = true;
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
}
// Allow another hint after a generous cool-down so the user doesn't
// get spammed if they ignore the first toast.
setTimeout(() => {
this.hintDispatched = false;
}, 30_000);
}
play(
@@ -136,34 +80,28 @@ class NotificationPlayer {
if (now - this.lastPlayAt < 600) return;
this.lastPlayAt = now;
const def = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!def) return;
const ctx = this.getCtx();
if (ctx) {
if (ctx.state === "suspended") ctx.resume().catch(() => {});
const baseGain = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
const t0 = ctx.currentTime + 0.01;
for (const step of def.steps) {
try {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = step.type ?? "sine";
osc.frequency.value = step.freq;
const start = t0 + (step.delay ?? 0);
const peak = baseGain * (step.gain ?? 0.4);
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(
Math.max(peak, 0.0002),
start + 0.01,
);
g.gain.exponentialRampToValueAtTime(0.0001, start + step.duration);
osc.connect(g).connect(ctx.destination);
osc.start(start);
osc.stop(start + step.duration + 0.05);
} catch {
/* ignore single-step failures */
}
}
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!entry) return;
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
}
try {
el.currentTime = 0;
} catch {
/* some browsers throw if not loaded */
}
el.volume = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
const promise = el.play();
if (promise && typeof promise.then === "function") {
promise.catch(() => {
// Most likely autoplay blocked — surface a hint exactly once
// per cool-down so the user knows to click anywhere to enable.
if (!this.unlocked) this.dispatchHint();
});
}
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
+8 -1
View File
@@ -292,11 +292,18 @@
"mute": "كتم الإشعارات",
"unmute": "إلغاء كتم الإشعارات",
"globalSound": "تشغيل صوت عند التنبيه",
"vibration": "اهتزاز عند التنبيه",
"volume": "مستوى الصوت",
"ordersEnabled": "صوت للطلبات الجديدة",
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
"vibrationOrder": "اهتزاز عند الطلبات الجديدة",
"vibrationMeeting": "اهتزاز عند تذكيرات الاجتماعات",
"preview": "تجربة",
"quickMute": "كتم الإشعارات",
"quickUnmute": "إلغاء كتم الإشعارات",
"mutedToast": "تم كتم الإشعارات",
"unmutedToast": "تم إلغاء كتم الإشعارات",
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات"
+8 -1
View File
@@ -295,11 +295,18 @@
"mute": "Mute notifications",
"unmute": "Unmute notifications",
"globalSound": "Play sound on alerts",
"vibration": "Vibrate on alerts",
"volume": "Volume",
"ordersEnabled": "Sound for new orders",
"meetingsEnabled": "Sound for meeting reminders",
"vibrationOrder": "Vibrate on new orders",
"vibrationMeeting": "Vibrate on meeting reminders",
"preview": "Preview",
"quickMute": "Mute notifications",
"quickUnmute": "Unmute notifications",
"mutedToast": "Notifications muted",
"unmutedToast": "Notifications unmuted",
"autoplayHintTitle": "Sounds need a click",
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
"slot": {
"order": "Orders",
"meeting": "Meetings"
+5 -1
View File
@@ -65,7 +65,10 @@ import {
type HomeClockSize,
} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
import { NotificationSettingsPicker } from "@/components/notification-settings";
import {
NotificationSettingsPicker,
QuickMuteButton,
} from "@/components/notification-settings";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -496,6 +499,7 @@ export default function HomePage() {
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<QuickMuteButton />
<NotificationSettingsPicker />
<button
onClick={toggleLanguage}
@@ -115,7 +115,8 @@ export interface UpdateNotificationPreferencesBody {
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
vibrationEnabledOrder?: boolean;
vibrationEnabledMeeting?: boolean;
notificationsMuted?: boolean;
/**
* @minimum 0
@@ -160,7 +161,8 @@ export interface AuthUser {
notificationSoundMeeting: NotificationSoundId;
notifyOrdersEnabled: boolean;
notifyMeetingsEnabled: boolean;
vibrationEnabled: boolean;
vibrationEnabledOrder: boolean;
vibrationEnabledMeeting: boolean;
notificationsMuted: boolean;
notificationVolume: number;
roles: string[];
+8 -3
View File
@@ -3004,7 +3004,9 @@ components:
type: boolean
notifyMeetingsEnabled:
type: boolean
vibrationEnabled:
vibrationEnabledOrder:
type: boolean
vibrationEnabledMeeting:
type: boolean
notificationsMuted:
type: boolean
@@ -3055,7 +3057,9 @@ components:
type: boolean
notifyMeetingsEnabled:
type: boolean
vibrationEnabled:
vibrationEnabledOrder:
type: boolean
vibrationEnabledMeeting:
type: boolean
notificationsMuted:
type: boolean
@@ -3082,7 +3086,8 @@ components:
- notificationSoundMeeting
- notifyOrdersEnabled
- notifyMeetingsEnabled
- vibrationEnabled
- vibrationEnabledOrder
- vibrationEnabledMeeting
- notificationsMuted
- notificationVolume
- roles
+14 -7
View File
@@ -76,7 +76,8 @@ export const LoginResponse = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
@@ -189,7 +190,8 @@ export const GetMeResponse = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
@@ -251,7 +253,8 @@ export const UpdateLanguageResponse = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
@@ -320,7 +323,8 @@ export const UpdateClockStyleResponse = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
@@ -350,7 +354,8 @@ export const UpdateNotificationPreferencesBody = zod.object({
.optional(),
notifyOrdersEnabled: zod.boolean().optional(),
notifyMeetingsEnabled: zod.boolean().optional(),
vibrationEnabled: zod.boolean().optional(),
vibrationEnabledOrder: zod.boolean().optional(),
vibrationEnabledMeeting: zod.boolean().optional(),
notificationsMuted: zod.boolean().optional(),
notificationVolume: zod
.number()
@@ -399,7 +404,8 @@ export const UpdateNotificationPreferencesResponse = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
@@ -466,7 +472,8 @@ export const UpdateClockHour12Response = zod.object({
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
vibrationEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
notificationsMuted: zod.boolean(),
notificationVolume: zod.number(),
roles: zod.array(zod.string()),
+2 -1
View File
@@ -18,7 +18,8 @@ export const usersTable = pgTable("users", {
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),
vibrationEnabledOrder: boolean("vibration_enabled_order").notNull().default(true),
vibrationEnabledMeeting: boolean("vibration_enabled_meeting").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(),