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:
Riyadh
2026-05-05 08:13:38 +00:00
parent f7b8de4eab
commit 876719f0eb
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;
+2
View File
@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster";
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 { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
@@ -31,6 +32,7 @@ const queryClient = new QueryClient({
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
return null;
}
@@ -0,0 +1,292 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateNotificationPreferences,
getGetMeQueryKey,
type AuthUser,
type NotificationSoundId,
} from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, Play } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ALL_SOUND_IDS, notificationPlayer } from "@/lib/notification-sounds";
import { useAuth } from "@/contexts/AuthContext";
type PrefsPatch = {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
notificationsMuted?: boolean;
notificationVolume?: number;
};
function applyOptimistic(prev: AuthUser | undefined, patch: PrefsPatch): AuthUser | undefined {
if (!prev) return prev;
return { ...prev, ...patch };
}
export function useUpdatePrefs() {
const queryClient = useQueryClient();
const mutation = useUpdateNotificationPreferences();
// Monotonic counter so out-of-order responses from rapid clicks (or a
// dragged slider) cannot overwrite newer optimistic state.
const seqRef = useRef(0);
const lastAppliedRef = useRef(0);
return (patch: PrefsPatch) => {
const seq = ++seqRef.current;
queryClient.cancelQueries({ queryKey: getGetMeQueryKey() }).catch(() => {});
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
const next = applyOptimistic(previous, patch);
if (next) queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), next);
mutation.mutate(
{ data: patch },
{
onSuccess: (data) => {
// Only the latest in-flight mutation may publish server state,
// otherwise a slow earlier response would clobber a newer one.
if (seq < lastAppliedRef.current) return;
lastAppliedRef.current = seq;
if (seq === seqRef.current) {
queryClient.setQueryData(getGetMeQueryKey(), data);
}
},
onError: () => {
// Only roll back if no newer write has been initiated; otherwise
// the newer optimistic state should stand.
if (seq !== seqRef.current) return;
if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
},
},
);
};
}
type Slot = "order" | "meeting";
function SoundList({
current,
onPick,
volume,
}: {
current: NotificationSoundId;
onPick: (id: NotificationSoundId) => void;
volume: number;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-0.5 max-h-56 overflow-y-auto">
{ALL_SOUND_IDS.map((id) => {
const isActive = id === current;
return (
<div
key={id}
className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg ${
isActive ? "bg-primary/10" : "hover:bg-foreground/5"
}`}
>
<button
type="button"
onClick={() => onPick(id)}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
{isActive ? (
<Check size={14} className="text-primary shrink-0" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span className="text-xs text-foreground font-medium truncate">
{t(`notifSettings.sounds.${id}`)}
</span>
</button>
<button
type="button"
onClick={() => {
notificationPlayer.unlock();
notificationPlayer.play(id, { volume });
}}
aria-label={t("notifSettings.preview")}
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
>
<Play size={12} />
</button>
</div>
);
})}
</div>
);
}
function Toggle({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors"
>
<span className="text-xs text-foreground font-medium truncate">{label}</span>
<span
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
active ? "bg-primary" : "bg-foreground/15"
}`}
aria-hidden="true"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
active
? "translate-x-[18px] rtl:translate-x-0.5"
: "translate-x-0.5 rtl:translate-x-[18px]"
}`}
/>
</span>
</button>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [slot, setSlot] = useState<Slot>("order");
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
const volume = user.notificationVolume;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t(muted ? "notifSettings.unmute" : "notifSettings.mute")}
aria-pressed={muted}
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" />
) : (
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2" align="end">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("notifSettings.title")}
</div>
<Toggle
active={!muted}
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" />
<div className="flex items-center gap-2 px-2 py-1.5">
<Volume2 size={14} className="text-muted-foreground" />
<input
type="range"
min={0}
max={100}
step={5}
value={volume}
onChange={(e) =>
apply({ notificationVolume: Number(e.target.value) })
}
className="flex-1 accent-primary"
aria-label={t("notifSettings.volume")}
/>
<span className="text-xs text-muted-foreground w-8 text-end tabular-nums">
{volume}
</span>
</div>
<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"
>
{(["order", "meeting"] as const).map((s) => {
const isActive = s === slot;
return (
<button
key={s}
type="button"
onClick={() => setSlot(s)}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`notifSettings.slot.${s}`)}
</button>
);
})}
</div>
<SoundList
current={
slot === "order"
? user.notificationSoundOrder
: user.notificationSoundMeeting
}
onPick={(id) =>
apply(
slot === "order"
? { notificationSoundOrder: id }
: { notificationSoundMeeting: id },
)
}
volume={volume}
/>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { notificationPlayer } from "@/lib/notification-sounds";
/**
* Browsers require a user gesture before audio can play. Listen for the
* first interaction anywhere on the page and unlock the shared audio
* context so subsequent socket-driven sounds work.
*/
export function useAudioUnlock(): void {
useEffect(() => {
if (typeof window === "undefined") return;
let done = false;
const unlock = () => {
if (done) return;
done = true;
notificationPlayer.unlock();
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
window.addEventListener("pointerdown", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
window.addEventListener("touchstart", unlock, { once: true });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
}, []);
}
@@ -15,6 +15,7 @@ import {
getGetRoleUsageQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/contexts/AuthContext";
import { notificationPlayer } from "@/lib/notification-sounds";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
@@ -40,6 +41,25 @@ export function useNotificationsSocket() {
queryKey: getListIncomingServiceOrdersQueryKey(),
});
}
// Per-user sound + vibration. Stay silent when the tab is in the
// foreground (the visible UI already conveys the change), when the
// user has globally muted notifications, or when this event type
// is opted-out.
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
if (user.notificationsMuted) return;
const isOrder = payload?.type === "order";
const isMeeting = payload?.type === "executive_meeting";
if (isOrder && !user.notifyOrdersEnabled) return;
if (isMeeting && !user.notifyMeetingsEnabled) return;
if (!isOrder && !isMeeting) return;
const soundId = isOrder
? user.notificationSoundOrder
: user.notificationSoundMeeting;
notificationPlayer.play(soundId, {
volume: user.notificationVolume,
vibrate: user.vibrationEnabled,
});
});
socket.on("order_updated", () => {
@@ -0,0 +1,179 @@
import type { NotificationSoundId } from "@workspace/api-client-react";
type ToneStep = {
freq: number;
duration: number;
delay?: number;
type?: OscillatorType;
gain?: number;
};
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;
class NotificationPlayer {
private ctx: AnyAudioContext | null = null;
private unlocked = false;
private lastPlayAt = 0;
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;
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the AudioContext.
*/
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;
}
play(
soundId: NotificationSoundId,
opts: { volume?: number; vibrate?: boolean } = {},
): void {
// Throttle: at most one sound every 600ms to avoid cacophony when
// many events arrive at once.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
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 */
}
}
}
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
try {
navigator.vibrate([60, 40, 60]);
} catch {
/* ignore */
}
}
}
}
export const notificationPlayer = new NotificationPlayer();
+25
View File
@@ -287,6 +287,31 @@
"adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق"
}
},
"notifSettings": {
"title": "أصوات الإشعارات",
"mute": "كتم الإشعارات",
"unmute": "إلغاء كتم الإشعارات",
"globalSound": "تشغيل صوت عند التنبيه",
"vibration": "اهتزاز عند التنبيه",
"volume": "مستوى الصوت",
"ordersEnabled": "صوت للطلبات الجديدة",
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
"preview": "تجربة",
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات"
},
"sounds": {
"ding": "رنين",
"chime": "نغمة",
"bell": "جرس",
"knock": "طرق",
"pop": "نقرة",
"alert": "تنبيه",
"beep": "صفير",
"soft": "هادئ"
}
},
"notifications": {
"title": "الإشعارات",
"markAllRead": "تحديد الكل كمقروء",
+25
View File
@@ -290,6 +290,31 @@
"noNotifications": "No new notifications",
"unread": "Unread"
},
"notifSettings": {
"title": "Notification sounds",
"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",
"preview": "Preview",
"slot": {
"order": "Orders",
"meeting": "Meetings"
},
"sounds": {
"ding": "Ding",
"chime": "Chime",
"bell": "Bell",
"knock": "Knock",
"pop": "Pop",
"alert": "Alert",
"beep": "Beep",
"soft": "Soft"
}
},
"admin": {
"title": "Admin Dashboard",
"manageApps": "Manage Apps",
+2
View File
@@ -65,6 +65,7 @@ import {
type HomeClockSize,
} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
import { NotificationSettingsPicker } from "@/components/notification-settings";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -495,6 +496,7 @@ export default function HomePage() {
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<NotificationSettingsPicker />
<button
onClick={toggleLanguage}
aria-label="Toggle language"
@@ -96,6 +96,34 @@ export interface UpdateClockStyleBody {
clockStyle: ClockStyle | null;
}
export type NotificationSoundId =
(typeof NotificationSoundId)[keyof typeof NotificationSoundId];
export const NotificationSoundId = {
ding: "ding",
chime: "chime",
bell: "bell",
knock: "knock",
pop: "pop",
alert: "alert",
beep: "beep",
soft: "soft",
} as const;
export interface UpdateNotificationPreferencesBody {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
notificationsMuted?: boolean;
/**
* @minimum 0
* @maximum 100
*/
notificationVolume?: number;
}
export interface UpdateClockHour12Body {
/**
* Whether to use 12-hour (AM/PM) clock format; null clears and uses the default (24-hour)
@@ -128,6 +156,13 @@ export interface AuthUser {
/** @nullable */
avatarUrl?: string | null;
isActive: boolean;
notificationSoundOrder: NotificationSoundId;
notificationSoundMeeting: NotificationSoundId;
notifyOrdersEnabled: boolean;
notifyMeetingsEnabled: boolean;
vibrationEnabled: boolean;
notificationsMuted: boolean;
notificationVolume: number;
roles: string[];
groups: GroupSummary[];
createdAt: string;
+89
View File
@@ -108,6 +108,7 @@ import type {
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateNotificationPreferencesBody,
UpdateRoleBody,
UpdateServiceBody,
UpdateServiceOrderStatusBody,
@@ -1036,6 +1037,94 @@ export const useUpdateClockStyle = <
return useMutation(getUpdateClockStyleMutationOptions(options));
};
/**
* @summary Update notification sound, vibration & mute preferences
*/
export const getUpdateNotificationPreferencesUrl = () => {
return `/api/auth/me/notification-preferences`;
};
export const updateNotificationPreferences = async (
updateNotificationPreferencesBody: UpdateNotificationPreferencesBody,
options?: RequestInit,
): Promise<AuthUser> => {
return customFetch<AuthUser>(getUpdateNotificationPreferencesUrl(), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateNotificationPreferencesBody),
});
};
export const getUpdateNotificationPreferencesMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
> => {
const mutationKey = ["updateNotificationPreferences"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
{ data: BodyType<UpdateNotificationPreferencesBody> }
> = (props) => {
const { data } = props ?? {};
return updateNotificationPreferences(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNotificationPreferencesMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNotificationPreferences>>
>;
export type UpdateNotificationPreferencesMutationBody =
BodyType<UpdateNotificationPreferencesBody>;
export type UpdateNotificationPreferencesMutationError =
ErrorType<ErrorResponse>;
/**
* @summary Update notification sound, vibration & mute preferences
*/
export const useUpdateNotificationPreferences = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNotificationPreferences>>,
TError,
{ data: BodyType<UpdateNotificationPreferencesBody> },
TContext
> => {
return useMutation(getUpdateNotificationPreferencesMutationOptions(options));
};
/**
* @summary Update preferred 12/24-hour clock format
*/
+70
View File
@@ -276,6 +276,31 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/auth/me/notification-preferences:
patch:
operationId: updateNotificationPreferences
tags: [auth]
summary: Update notification sound, vibration & mute preferences
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateNotificationPreferencesBody"
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/AuthUser"
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/auth/me/clock-hour12:
patch:
operationId: updateClockHour12
@@ -2964,6 +2989,30 @@ components:
required:
- clockStyle
NotificationSoundId:
type: string
enum: [ding, chime, bell, knock, pop, alert, beep, soft]
UpdateNotificationPreferencesBody:
type: object
properties:
notificationSoundOrder:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundMeeting:
$ref: "#/components/schemas/NotificationSoundId"
notifyOrdersEnabled:
type: boolean
notifyMeetingsEnabled:
type: boolean
vibrationEnabled:
type: boolean
notificationsMuted:
type: boolean
notificationVolume:
type: integer
minimum: 0
maximum: 100
UpdateClockHour12Body:
type: object
properties:
@@ -2998,6 +3047,20 @@ components:
type: ["string", "null"]
isActive:
type: boolean
notificationSoundOrder:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundMeeting:
$ref: "#/components/schemas/NotificationSoundId"
notifyOrdersEnabled:
type: boolean
notifyMeetingsEnabled:
type: boolean
vibrationEnabled:
type: boolean
notificationsMuted:
type: boolean
notificationVolume:
type: integer
roles:
type: array
items:
@@ -3015,6 +3078,13 @@ components:
- email
- preferredLanguage
- isActive
- notificationSoundOrder
- notificationSoundMeeting
- notifyOrdersEnabled
- notifyMeetingsEnabled
- vibrationEnabled
- notificationsMuted
- notificationVolume
- roles
- groups
- createdAt
+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({
+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()),
});