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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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": "تحديد الكل كمقروء",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user