mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat: Notifications
This commit is contained in:
parent
f36cd23123
commit
ab6a511d31
257
src/app/(dashboard)/notifications/page.tsx
Normal file
257
src/app/(dashboard)/notifications/page.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Notification } from "@/types";
|
||||
import { Bell, CheckCheck, Loader2, UserPlus } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Icon per notification type. Only one type exists today
|
||||
// (conversation_assigned) but this keeps future types a one-line add.
|
||||
const TYPE_ICON: Record<Notification["type"], typeof Bell> = {
|
||||
conversation_assigned: UserPlus,
|
||||
};
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<Notification[] | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const { data, error: fetchErr } = await supabase
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(100);
|
||||
if (fetchErr) {
|
||||
setError(fetchErr.message);
|
||||
return;
|
||||
}
|
||||
setNotifications((data ?? []) as Notification[]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Realtime — new assignments appear without a refresh, and a
|
||||
// "mark all read" fired from another tab/device stays in sync here.
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
const channel = supabase
|
||||
.channel("notifications-page")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notifications" },
|
||||
(payload) => {
|
||||
if (payload.eventType === "INSERT") {
|
||||
const row = payload.new as Notification;
|
||||
setNotifications((prev) => {
|
||||
if (!prev) return [row];
|
||||
if (prev.some((n) => n.id === row.id)) return prev;
|
||||
return [row, ...prev];
|
||||
});
|
||||
} else if (payload.eventType === "UPDATE") {
|
||||
const row = payload.new as Notification;
|
||||
setNotifications((prev) =>
|
||||
prev?.map((n) => (n.id === row.id ? { ...n, ...row } : n)) ??
|
||||
prev,
|
||||
);
|
||||
} else if (payload.eventType === "DELETE") {
|
||||
const oldRow = payload.old as Partial<Notification>;
|
||||
setNotifications(
|
||||
(prev) => prev?.filter((n) => n.id !== oldRow.id) ?? prev,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markRead = useCallback(async (id: string) => {
|
||||
// Optimistic — the row is already visually "read" by the time the
|
||||
// request lands, so the UI doesn't wait on the round-trip.
|
||||
setNotifications(
|
||||
(prev) =>
|
||||
prev?.map((n) =>
|
||||
n.id === id && !n.read_at
|
||||
? { ...n, read_at: new Date().toISOString() }
|
||||
: n,
|
||||
) ?? prev,
|
||||
);
|
||||
const supabase = createClient();
|
||||
await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.is("read_at", null);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(n: Notification) => {
|
||||
if (!n.read_at) markRead(n.id);
|
||||
if (n.conversation_id) {
|
||||
router.push(`/inbox?c=${n.conversation_id}`);
|
||||
}
|
||||
},
|
||||
[markRead, router],
|
||||
);
|
||||
|
||||
const unreadIds = notifications?.filter((n) => !n.read_at).map((n) => n.id) ?? [];
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
if (unreadIds.length === 0) return;
|
||||
setMarkingAll(true);
|
||||
const now = new Date().toISOString();
|
||||
setNotifications(
|
||||
(prev) => prev?.map((n) => (n.read_at ? n : { ...n, read_at: now })) ?? prev,
|
||||
);
|
||||
const supabase = createClient();
|
||||
const { error: updateErr } = await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: now })
|
||||
.is("read_at", null);
|
||||
setMarkingAll(false);
|
||||
if (updateErr) {
|
||||
toast.error("Failed to mark all as read");
|
||||
load();
|
||||
}
|
||||
}, [unreadIds.length, load]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notifications === null) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Conversations other teammates assign to you show up here.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unreadIds.length === 0 || markingAll}
|
||||
onClick={markAllRead}
|
||||
>
|
||||
{markingAll ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
)}
|
||||
Mark all as read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Bell className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-white">
|
||||
No notifications yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
You'll see an alert here when someone assigns you a
|
||||
conversation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{notifications.map((n) => {
|
||||
const Icon = TYPE_ICON[n.type] ?? Bell;
|
||||
const isUnread = !n.read_at;
|
||||
return (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(n)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors",
|
||||
isUnread
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "border-slate-800 bg-slate-900 hover:border-slate-700",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isUnread ? "bg-primary/15" : "bg-slate-800",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
isUnread ? "text-primary" : "text-slate-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm font-semibold",
|
||||
isUnread ? "text-white" : "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{n.title}
|
||||
</span>
|
||||
{isUnread && (
|
||||
<span
|
||||
aria-label="Unread"
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||
{n.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
{formatDistanceToNow(new Date(n.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ import {
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
"/inbox": "Inbox",
|
||||
"/notifications": "Notifications",
|
||||
"/contacts": "Contacts",
|
||||
"/pipelines": "Pipelines",
|
||||
"/broadcasts": "Broadcasts",
|
||||
|
||||
@ -6,7 +6,9 @@ import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTotalUnread } from "@/hooks/use-total-unread";
|
||||
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
|
||||
import {
|
||||
Bell,
|
||||
Crown,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
@ -89,6 +91,7 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell },
|
||||
{ href: "/contacts", label: "Contacts", icon: Users },
|
||||
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
||||
@ -110,6 +113,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
|
||||
const totalUnread = useTotalUnread();
|
||||
const unreadNotifications = useUnreadNotifications();
|
||||
// Only surface the account-name strip when it actually carries
|
||||
// information. A solo user's personal account is named after them
|
||||
// (the 017 signup trigger seeds it from `full_name`), so showing it
|
||||
@ -207,6 +211,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
const showUnreadDot =
|
||||
item.href === "/inbox" && totalUnread > 0 && !isActive;
|
||||
|
||||
// Unlike the inbox dot, the notifications count stays visible
|
||||
// even while the page is active — it reflects unread state
|
||||
// (cleared by marking notifications read), not "currently
|
||||
// viewing this section".
|
||||
const showNotificationBadge =
|
||||
item.href === "/notifications" && unreadNotifications > 0;
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
@ -238,6 +249,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
)}
|
||||
{showNotificationBadge && (
|
||||
<span
|
||||
aria-label={`${unreadNotifications} unread notification${unreadNotifications === 1 ? "" : "s"}`}
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground"
|
||||
>
|
||||
{unreadNotifications > 9 ? "9+" : unreadNotifications}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
64
src/hooks/use-unread-notifications.ts
Normal file
64
src/hooks/use-unread-notifications.ts
Normal file
@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Notification } from "@/types";
|
||||
|
||||
/**
|
||||
* Count of unread notifications for the current user. Used by the
|
||||
* sidebar to surface a badge on the Notifications nav entry.
|
||||
*
|
||||
* RLS on `notifications` already scopes every read to `auth.uid() =
|
||||
* user_id`, so no explicit filter is needed here — same pattern as
|
||||
* `useTotalUnread` for conversations.
|
||||
*/
|
||||
export function useUnreadNotifications(): number {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
// head:true skips fetching rows — we only need the `count`
|
||||
// supabase-js returns alongside the (empty) response body.
|
||||
const { count: unreadCount, error } = await supabase
|
||||
.from("notifications")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.is("read_at", null);
|
||||
if (cancelled || error) return;
|
||||
setCount(unreadCount ?? 0);
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel("notifications-unread-count")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notifications" },
|
||||
(payload) => {
|
||||
if (payload.eventType === "INSERT") {
|
||||
const row = payload.new as Notification;
|
||||
if (!row.read_at) setCount((n) => n + 1);
|
||||
} else if (payload.eventType === "UPDATE") {
|
||||
const oldRow = payload.old as Partial<Notification>;
|
||||
const newRow = payload.new as Notification;
|
||||
const wasUnread = !oldRow.read_at;
|
||||
const isUnread = !newRow.read_at;
|
||||
if (wasUnread && !isUnread) setCount((n) => Math.max(0, n - 1));
|
||||
if (!wasUnread && isUnread) setCount((n) => n + 1);
|
||||
} else if (payload.eventType === "DELETE") {
|
||||
const oldRow = payload.old as Partial<Notification>;
|
||||
if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1));
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
}
|
||||
@ -158,6 +158,28 @@ export interface Conversation {
|
||||
contact?: Contact;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Notifications (migration 023)
|
||||
// ============================================================
|
||||
|
||||
export type NotificationType = 'conversation_assigned';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
account_id: string;
|
||||
/** Recipient — the agent this notification is for. */
|
||||
user_id: string;
|
||||
type: NotificationType;
|
||||
conversation_id?: string;
|
||||
contact_id?: string;
|
||||
/** Who triggered it. Null when an automation/system assigned it. */
|
||||
actor_user_id?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
read_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SenderType = 'customer' | 'agent' | 'bot';
|
||||
export type ContentType =
|
||||
| 'text'
|
||||
|
||||
119
supabase/migrations/023_notifications.sql
Normal file
119
supabase/migrations/023_notifications.sql
Normal file
@ -0,0 +1,119 @@
|
||||
- ============================================================
|
||||
-- NOTIFICATIONS
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
-- Recipient — the agent this notification is for.
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'conversation_assigned'
|
||||
CHECK (type IN ('conversation_assigned')),
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
-- Who triggered the notification. NULL means an automation / the
|
||||
-- system did it rather than a signed-in teammate.
|
||||
actor_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created
|
||||
ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON notifications(user_id)
|
||||
WHERE read_at IS NULL;
|
||||
|
||||
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Recipients can read and mark their own notifications as read.
|
||||
-- No client INSERT/DELETE policy — rows are created exclusively by
|
||||
-- the SECURITY DEFINER trigger function below.
|
||||
DROP POLICY IF EXISTS notifications_select ON notifications;
|
||||
DROP POLICY IF EXISTS notifications_update ON notifications;
|
||||
CREATE POLICY notifications_select ON notifications FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
CREATE POLICY notifications_update ON notifications FOR UPDATE
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- TRIGGER — notify on conversation assignment
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION notify_conversation_assigned()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_contact_name TEXT;
|
||||
v_actor_name TEXT;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.assigned_agent_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
ELSE
|
||||
IF NEW.assigned_agent_id IS NULL
|
||||
OR NEW.assigned_agent_id IS NOT DISTINCT FROM OLD.assigned_agent_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Skip self-assignment — nothing to notify the agent about.
|
||||
IF auth.uid() IS NOT NULL AND auth.uid() = NEW.assigned_agent_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(NULLIF(name, ''), phone) INTO v_contact_name
|
||||
FROM contacts WHERE id = NEW.contact_id;
|
||||
|
||||
IF auth.uid() IS NOT NULL THEN
|
||||
SELECT full_name INTO v_actor_name
|
||||
FROM profiles WHERE user_id = auth.uid();
|
||||
END IF;
|
||||
|
||||
INSERT INTO notifications (
|
||||
account_id, user_id, type, conversation_id, contact_id,
|
||||
actor_user_id, title, body
|
||||
) VALUES (
|
||||
NEW.account_id,
|
||||
NEW.assigned_agent_id,
|
||||
'conversation_assigned',
|
||||
NEW.id,
|
||||
NEW.contact_id,
|
||||
auth.uid(),
|
||||
'New conversation assigned',
|
||||
COALESCE(v_actor_name, 'Someone') || ' assigned you a conversation with '
|
||||
|| COALESCE(v_contact_name, 'a contact')
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Never let a notification failure block the assignment itself.
|
||||
RAISE WARNING 'Failed to create assignment notification for conversation %: %', NEW.id, SQLERRM;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION notify_conversation_assigned() OWNER TO postgres;
|
||||
|
||||
DROP TRIGGER IF EXISTS on_conversation_assigned ON conversations;
|
||||
CREATE TRIGGER on_conversation_assigned
|
||||
AFTER INSERT OR UPDATE OF assigned_agent_id ON conversations
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_conversation_assigned();
|
||||
|
||||
-- ============================================================
|
||||
-- ENABLE REALTIME
|
||||
-- ============================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND tablename = 'notifications'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE notifications;
|
||||
END IF;
|
||||
END $$;
|
||||
Loading…
Reference in New Issue
Block a user