From 995e97ca759d24c306d2bc277f9456fc116f6a4c Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Tue, 24 Mar 2026 23:58:58 -0600 Subject: [PATCH 01/26] feat: add comprehensive chat moderation tools for streamers Implement full moderation system including: - Ban/timeout users with duration options (1m, 5m, 10m, 1h, permanent) - Delete messages with context menu - Slow mode (3s, 5s, 10s, 30s intervals) - Follower-only chat mode - Link blocking with URL regex detection - Active bans management panel Database changes: - Add chat_bans table with expires_at for timeouts - Add slow_mode_seconds, follower_only_chat, link_blocking to users table API endpoints: - POST /api/streams/chat/ban - ban/timeout users - DELETE /api/streams/chat/ban/[username] - unban users - GET /api/streams/chat/ban - list active bans - PATCH /api/streams/settings - update chat settings - Updated POST /api/streams/chat with enforcement logic UI components: - Right-click context menu on chat messages (stream owner only) - ChatModerationSettings panel in stream manager - Ban list with unban functionality - Settings toggles for follower-only and link blocking Enforcement: - Server-side validation for all moderation rules - 429 responses with Retry-After headers for timeouts - Clear error messages for banned/timed-out users --- app/api/streams/chat/ban/[username]/route.ts | 56 ++++ app/api/streams/chat/ban/route.ts | 110 ++++++++ app/api/streams/chat/route.ts | 118 +++++++- app/api/streams/settings/route.ts | 125 +++++++++ app/dashboard/stream-manager/page.tsx | 4 +- .../stream-manager/ChatModerationSettings.tsx | 255 ++++++++++++++++++ components/stream/chat-section.tsx | 159 ++++++++++- components/stream/view-stream.tsx | 5 + db/schema.sql | 30 ++- hooks/useChat.ts | 62 +++++ types/chat.ts | 18 ++ 11 files changed, 931 insertions(+), 11 deletions(-) create mode 100644 app/api/streams/chat/ban/[username]/route.ts create mode 100644 app/api/streams/chat/ban/route.ts create mode 100644 app/api/streams/settings/route.ts create mode 100644 components/dashboard/stream-manager/ChatModerationSettings.tsx diff --git a/app/api/streams/chat/ban/[username]/route.ts b/app/api/streams/chat/ban/[username]/route.ts new file mode 100644 index 00000000..cb4cc629 --- /dev/null +++ b/app/api/streams/chat/ban/[username]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Delete the ban + const result = await sql` + DELETE FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND banned_user = ${username} + `; + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Ban not found" }, { status: 404 }); + } + + return NextResponse.json( + { message: "User unbanned successfully" }, + { status: 200 } + ); + } catch (error) { + console.error("Unban user error:", error); + return NextResponse.json( + { error: "Failed to unban user" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/chat/ban/route.ts b/app/api/streams/chat/ban/route.ts new file mode 100644 index 00000000..419f85e8 --- /dev/null +++ b/app/api/streams/chat/ban/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function POST(req: NextRequest) { + try { + const { streamOwnerWallet, bannedUser, durationMinutes, reason } = + await req.json(); + + if (!streamOwnerWallet || !bannedUser) { + return NextResponse.json( + { error: "Stream owner wallet and banned user are required" }, + { status: 400 } + ); + } + + // Verify requester is the stream owner + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Calculate expires_at for timeouts, null for permanent bans + let expiresAt: string | null = null; + if (durationMinutes && durationMinutes > 0) { + const now = new Date(); + const expiresDate = new Date(now.getTime() + durationMinutes * 60 * 1000); + expiresAt = expiresDate.toISOString(); + } + + // Insert or update ban record + await sql` + INSERT INTO chat_bans (stream_owner, banned_user, expires_at, reason) + VALUES (${streamOwner}, ${bannedUser}, ${expiresAt}, ${reason || null}) + ON CONFLICT (stream_owner, banned_user) + DO UPDATE SET + banned_at = now(), + expires_at = ${expiresAt}, + reason = ${reason || null} + `; + + return NextResponse.json( + { + message: expiresAt + ? `User timed out for ${durationMinutes} minute(s)` + : "User banned permanently", + bannedUser, + expiresAt, + }, + { status: 201 } + ); + } catch (error) { + console.error("Ban user error:", error); + return NextResponse.json({ error: "Failed to ban user" }, { status: 500 }); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Get active bans (permanent or not expired) + const bansResult = await sql` + SELECT + id, + banned_user, + banned_at, + expires_at, + reason + FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY banned_at DESC + `; + + return NextResponse.json({ bans: bansResult.rows }, { status: 200 }); + } catch (error) { + console.error("Get bans error:", error); + return NextResponse.json({ error: "Failed to get bans" }, { status: 500 }); + } +} diff --git a/app/api/streams/chat/route.ts b/app/api/streams/chat/route.ts index 788c7870..f365ce07 100644 --- a/app/api/streams/chat/route.ts +++ b/app/api/streams/chat/route.ts @@ -46,13 +46,17 @@ export async function POST(req: NextRequest) { ); } - // Combined query: look up sender + stream + active session in one round-trip + // Combined query: look up sender + stream + active session + moderation settings in one round-trip const result = await sql` SELECT sender.id AS sender_id, sender.username AS sender_username, streamer.id AS streamer_id, + streamer.username AS streamer_username, streamer.is_live, + streamer.slow_mode_seconds, + streamer.follower_only_chat, + streamer.link_blocking, ( SELECT ss.id FROM stream_sessions ss WHERE ss.user_id = streamer.id AND ss.ended_at IS NULL @@ -71,7 +75,16 @@ export async function POST(req: NextRequest) { ); } - const { sender_id, sender_username, is_live, session_id } = result.rows[0]; + const { + sender_id, + sender_username, + streamer_username, + is_live, + session_id, + slow_mode_seconds, + follower_only_chat, + link_blocking, + } = result.rows[0]; if (!is_live) { return NextResponse.json( @@ -87,6 +100,107 @@ export async function POST(req: NextRequest) { ); } + // 1. Check for permanent ban + const permanentBanResult = await sql` + SELECT id FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NULL + `; + + if (permanentBanResult.rows.length > 0) { + return NextResponse.json( + { error: "You are banned from this chat" }, + { status: 403 } + ); + } + + // 2. Check for active timeout + const timeoutResult = await sql` + SELECT expires_at FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NOT NULL + AND expires_at > now() + `; + + if (timeoutResult.rows.length > 0) { + const expiresAt = new Date(timeoutResult.rows[0].expires_at); + const now = new Date(); + const secondsRemaining = Math.ceil( + (expiresAt.getTime() - now.getTime()) / 1000 + ); + + return NextResponse.json( + { + error: `You are timed out for ${Math.ceil(secondsRemaining / 60)} minute(s)`, + }, + { + status: 429, + headers: { "Retry-After": secondsRemaining.toString() }, + } + ); + } + + // 3. Check slow mode + if (slow_mode_seconds > 0) { + const lastMessageResult = await sql` + SELECT created_at FROM chat_messages + WHERE stream_session_id = ${session_id} + AND user_id = ${sender_id} + ORDER BY created_at DESC + LIMIT 1 + `; + + if (lastMessageResult.rows.length > 0) { + const lastMessageTime = new Date(lastMessageResult.rows[0].created_at); + const now = new Date(); + const secondsSinceLastMessage = + (now.getTime() - lastMessageTime.getTime()) / 1000; + + if (secondsSinceLastMessage < slow_mode_seconds) { + const waitSeconds = Math.ceil( + slow_mode_seconds - secondsSinceLastMessage + ); + return NextResponse.json( + { error: `Slow mode is enabled. Wait ${waitSeconds} second(s)` }, + { + status: 429, + headers: { "Retry-After": waitSeconds.toString() }, + } + ); + } + } + } + + // 4. Check follower-only mode + if (follower_only_chat) { + const followerResult = await sql` + SELECT id FROM users + WHERE username = ${sender_username} + AND ${streamer_username} = ANY(following) + `; + + if (followerResult.rows.length === 0) { + return NextResponse.json( + { error: "This chat is in follower-only mode" }, + { status: 403 } + ); + } + } + + // 5. Check link blocking + if (link_blocking) { + const urlRegex = + /(https?:\/\/[^\s]+)|(www\.[^\s]+)|([a-zA-Z0-9-]+\.(com|net|org|io|dev|gg|tv|me|co)[^\s]*)/gi; + if (urlRegex.test(content)) { + return NextResponse.json( + { error: "Links are not allowed in this chat" }, + { status: 400 } + ); + } + } + const messageResult = await sql` INSERT INTO chat_messages ( user_id, diff --git a/app/api/streams/settings/route.ts b/app/api/streams/settings/route.ts new file mode 100644 index 00000000..1f5ba3e1 --- /dev/null +++ b/app/api/streams/settings/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function PATCH(req: NextRequest) { + try { + const { wallet, slowModeSeconds, followerOnlyChat, linkBlocking } = + await req.json(); + + if (!wallet) { + return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + } + + // Validate slow mode value + if ( + slowModeSeconds !== undefined && + ![0, 3, 5, 10, 30].includes(slowModeSeconds) + ) { + return NextResponse.json( + { error: "Invalid slow mode value. Must be 0, 3, 5, 10, or 30" }, + { status: 400 } + ); + } + + // Build update query dynamically based on provided fields + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (slowModeSeconds !== undefined) { + updates.push(`slow_mode_seconds = $${paramIndex}`); + values.push(slowModeSeconds); + paramIndex++; + } + + if (followerOnlyChat !== undefined) { + updates.push(`follower_only_chat = $${paramIndex}`); + values.push(followerOnlyChat); + paramIndex++; + } + + if (linkBlocking !== undefined) { + updates.push(`link_blocking = $${paramIndex}`); + values.push(linkBlocking); + paramIndex++; + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No settings provided to update" }, + { status: 400 } + ); + } + + values.push(wallet); + + const query = ` + UPDATE users + SET ${updates.join(", ")}, updated_at = CURRENT_TIMESTAMP + WHERE wallet = $${paramIndex} + RETURNING slow_mode_seconds, follower_only_chat, link_blocking + `; + + const result = await sql.query(query, values); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + message: "Settings updated successfully", + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Update settings error:", error); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const wallet = searchParams.get("wallet"); + + if (!wallet) { + return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + } + + const result = await sql` + SELECT slow_mode_seconds, follower_only_chat, link_blocking + FROM users + WHERE wallet = ${wallet} + `; + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Get settings error:", error); + return NextResponse.json( + { error: "Failed to get settings" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index e0dc166b..ebc136d7 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -9,6 +9,7 @@ import ActivityFeed from "@/components/dashboard/stream-manager/ActivityFeed"; import Chat from "@/components/dashboard/stream-manager/Chat"; import StreamInfo from "@/components/dashboard/stream-manager/StreamInfo"; import StreamSettings from "@/components/dashboard/stream-manager/StreamSettings"; +import ChatModerationSettings from "@/components/dashboard/stream-manager/ChatModerationSettings"; import StreamInfoModal from "@/components/dashboard/common/StreamInfoModal"; import { motion } from "framer-motion"; import { Users, UserPlus, Coins, Timer } from "lucide-react"; @@ -205,7 +206,7 @@ export default function StreamManagerPage() { - {/* Right column: Chat + Stream info + Tip wallet */} + {/* Right column: Chat + Stream info + Chat moderation + Tip wallet */}
@@ -217,6 +218,7 @@ export default function StreamManagerPage() { }} onEditClick={() => setIsStreamInfoModalOpen(true)} /> +
diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx new file mode 100644 index 00000000..47e448e1 --- /dev/null +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Shield, + X, + Ban, + Clock, + Users, + Link as LinkIcon, + Loader2, +} from "lucide-react"; +import { useStellarWallet } from "@/contexts/stellar-wallet-context"; +import type { ChatBan, StreamChatSettings } from "@/types/chat"; + +export default function ChatModerationSettings() { + const { publicKey, privyWallet } = useStellarWallet(); + const walletAddress = publicKey || privyWallet?.wallet || null; + const [isMinimized, setIsMinimized] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [bans, setBans] = useState([]); + const [settings, setSettings] = useState({ + slowModeSeconds: 0, + followerOnlyChat: false, + linkBlocking: false, + }); + + // Load settings and bans + useEffect(() => { + if (!walletAddress) { + setIsLoading(false); + return; + } + + const loadData = async () => { + try { + const [settingsRes, bansRes] = await Promise.all([ + fetch(`/api/streams/settings?wallet=${walletAddress}`), + fetch(`/api/streams/chat/ban?streamOwnerWallet=${walletAddress}`), + ]); + + if (settingsRes.ok) { + const data = await settingsRes.json(); + setSettings(data.settings); + } + + if (bansRes.ok) { + const data = await bansRes.json(); + setBans(data.bans); + } + } catch (error) { + console.error("Failed to load moderation data:", error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [walletAddress]); + + const updateSetting = async ( + key: keyof StreamChatSettings, + value: number | boolean + ) => { + if (!walletAddress) return; + + setIsSaving(true); + try { + const res = await fetch("/api/streams/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + wallet: walletAddress, + [key]: value, + }), + }); + + if (res.ok) { + const data = await res.json(); + setSettings(data.settings); + } + } catch (error) { + console.error("Failed to update setting:", error); + } finally { + setIsSaving(false); + } + }; + + const unbanUser = async (username: string) => { + if (!walletAddress) return; + + try { + const res = await fetch( + `/api/streams/chat/ban/${username}?streamOwnerWallet=${walletAddress}`, + { method: "DELETE" } + ); + + if (res.ok) { + setBans(prev => prev.filter(ban => ban.bannedUser !== username)); + } + } catch (error) { + console.error("Failed to unban user:", error); + } + }; + + if (isMinimized) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + + Chat Moderation + +
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : !walletAddress ? ( +

+ No wallet connected +

+ ) : ( + <> + {/* Slow Mode */} +
+
+ + +
+ +
+ + {/* Follower-only Chat */} +
+ +
+ + {/* Link Blocking */} +
+ +
+ + {/* Active Bans */} + {bans.length > 0 && ( +
+
+ + + Active Bans ({bans.length}) + +
+
+ {bans.map(ban => ( +
+
+

+ {ban.bannedUser} +

+ {ban.expiresAt && ( +

+ Timeout expires:{" "} + {new Date(ban.expiresAt).toLocaleString()} +

+ )} +
+ +
+ ))} +
+
+ )} + + )} +
+
+ ); +} diff --git a/components/stream/chat-section.tsx b/components/stream/chat-section.tsx index f09232c7..2e50c112 100644 --- a/components/stream/chat-section.tsx +++ b/components/stream/chat-section.tsx @@ -3,12 +3,24 @@ import type React from "react"; import { useState, useRef, useEffect } from "react"; -import { ChevronRight, Send, Smile, GiftIcon, Wallet } from "lucide-react"; +import { + ChevronRight, + Send, + Smile, + GiftIcon, + Wallet, + MoreVertical, + Trash2, + Ban, + Clock, +} from "lucide-react"; import type { ChatMessage } from "@/types/chat"; interface ChatSectionProps { messages: ChatMessage[]; onSendMessage: (message: string) => void; + onDeleteMessage?: (messageId: number) => void; + onBanUser?: (username: string, durationMinutes?: number) => void; isCollapsible?: boolean; isFullscreen?: boolean; className?: string; @@ -16,11 +28,14 @@ interface ChatSectionProps { showChat?: boolean; isWalletConnected?: boolean; isSending?: boolean; + isStreamOwner?: boolean; } const ChatSection = ({ messages, onSendMessage, + onDeleteMessage, + onBanUser, isCollapsible = true, isFullscreen = false, className = "", @@ -28,9 +43,17 @@ const ChatSection = ({ showChat = true, isWalletConnected = false, isSending = false, + isStreamOwner = false, }: ChatSectionProps) => { const [chatMessage, setChatMessage] = useState(""); const chatContainerRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ + messageId: number; + username: string; + x: number; + y: number; + showTimeoutSubmenu?: boolean; + } | null>(null); // Auto-scroll to bottom when new messages arrive useEffect(() => { @@ -40,6 +63,44 @@ const ChatSection = ({ } }, [messages]); + // Close context menu on click outside + useEffect(() => { + const handleClickOutside = () => setContextMenu(null); + if (contextMenu) { + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + } + }, [contextMenu]); + + const handleContextMenu = ( + e: React.MouseEvent, + messageId: number, + username: string + ) => { + if (!isStreamOwner) return; + e.preventDefault(); + setContextMenu({ + messageId, + username, + x: e.clientX, + y: e.clientY, + }); + }; + + const handleDeleteMessage = () => { + if (contextMenu && onDeleteMessage) { + onDeleteMessage(contextMenu.messageId); + setContextMenu(null); + } + }; + + const handleBanUser = (durationMinutes?: number) => { + if (contextMenu && onBanUser) { + onBanUser(contextMenu.username, durationMinutes); + setContextMenu(null); + } + }; + const handleSendMessage = () => { if (!chatMessage.trim() || !isWalletConnected || isSending) { return; @@ -111,13 +172,16 @@ const ChatSection = ({ messages.map(message => (
+ handleContextMenu(e, message.id, message.username) + } >
-
+
{message.message}
+ {isStreamOwner && ( + + )}
)) )} @@ -175,6 +255,79 @@ const ChatSection = ({
)}
+ + {/* Context Menu */} + {contextMenu && isStreamOwner && ( +
e.stopPropagation()} + > + +
+ + {contextMenu.showTimeoutSubmenu && ( +
+ + + + +
+ )} +
+ +
+ )} ); }; diff --git a/components/stream/view-stream.tsx b/components/stream/view-stream.tsx index 1276210b..06147336 100644 --- a/components/stream/view-stream.tsx +++ b/components/stream/view-stream.tsx @@ -286,6 +286,8 @@ const ViewStream = ({ const { messages: chatMessages, sendMessage, + deleteMessage, + banUser, isSending, } = useChat(userData?.playbackId, address, isLive); @@ -955,6 +957,8 @@ const ViewStream = ({ )} diff --git a/db/schema.sql b/db/schema.sql index f1469aca..4ba810fc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -43,6 +43,22 @@ ADD COLUMN IF NOT EXISTS followers UUID[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS following UUID[]; +-- Chat moderation settings +ALTER TABLE users ADD COLUMN IF NOT EXISTS slow_mode_seconds INT DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS follower_only_chat BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS link_blocking BOOLEAN DEFAULT false; + +-- Chat bans table +CREATE TABLE IF NOT EXISTS chat_bans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_owner TEXT NOT NULL REFERENCES users(username), + banned_user TEXT NOT NULL, + banned_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ, + reason TEXT, + UNIQUE(stream_owner, banned_user) +); + CREATE TABLE IF NOT EXISTS stream_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, @@ -67,17 +83,18 @@ CREATE TABLE IF NOT EXISTS stream_sessions ( ); CREATE TABLE IF NOT EXISTS chat_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id SERIAL PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE, + username VARCHAR(255) NOT NULL, stream_session_id UUID REFERENCES stream_sessions(id) ON DELETE CASCADE, - + content TEXT NOT NULL, - message_type VARCHAR(20) DEFAULT 'message', - + message_type VARCHAR(20) DEFAULT 'message', + is_deleted BOOLEAN DEFAULT FALSE, is_moderated BOOLEAN DEFAULT FALSE, moderated_by UUID REFERENCES users(id), - + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -147,6 +164,9 @@ CREATE INDEX IF NOT EXISTS idx_stream_categories_title ON stream_categories(titl CREATE INDEX IF NOT EXISTS idx_stream_categories_active ON stream_categories(is_active); CREATE INDEX IF NOT EXISTS idx_tags_title ON tags(title); CREATE INDEX IF NOT EXISTS idx_tags_title_lower ON tags(LOWER(title)); +CREATE INDEX IF NOT EXISTS idx_chat_bans_stream_owner ON chat_bans(stream_owner); +CREATE INDEX IF NOT EXISTS idx_chat_bans_banned_user ON chat_bans(banned_user); +CREATE INDEX IF NOT EXISTS idx_chat_bans_expires_at ON chat_bans(expires_at); INSERT INTO stream_categories (title, description, tags) VALUES ('Gaming', 'Video game streaming and gameplay', ARRAY['gaming', 'esports', 'gameplay']), diff --git a/hooks/useChat.ts b/hooks/useChat.ts index ee2aa174..eebcce92 100644 --- a/hooks/useChat.ts +++ b/hooks/useChat.ts @@ -197,10 +197,72 @@ export function useChat( [wallet, mutate] ); + const banUser = useCallback( + async (username: string, durationMinutes?: number) => { + if (!wallet) { + return; + } + + try { + const res = await fetch("/api/streams/chat/ban", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + streamOwnerWallet: wallet, + bannedUser: username, + durationMinutes, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to ban user"); + } + + // Revalidate messages to reflect the ban + await mutate(); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to ban user"; + setSendError(errorMessage); + } + }, + [wallet, mutate] + ); + + const unbanUser = useCallback( + async (username: string) => { + if (!wallet) { + return; + } + + try { + const res = await fetch( + `/api/streams/chat/ban/${username}?streamOwnerWallet=${wallet}`, + { + method: "DELETE", + } + ); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to unban user"); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to unban user"; + setSendError(errorMessage); + } + }, + [wallet] + ); + return { messages, sendMessage, deleteMessage, + banUser, + unbanUser, isLoading, isSending, error: error?.message || sendError, diff --git a/types/chat.ts b/types/chat.ts index 8cf98daf..0e9f3bb4 100644 --- a/types/chat.ts +++ b/types/chat.ts @@ -43,7 +43,25 @@ export interface UseChatReturn { messages: ChatMessage[]; sendMessage: (content: string) => Promise; deleteMessage: (messageId: number) => Promise; + banUser: (username: string, durationMinutes?: number) => Promise; + unbanUser: (username: string) => Promise; isLoading: boolean; isSending: boolean; error: string | null; } + +/** Chat ban record */ +export interface ChatBan { + id: string; + bannedUser: string; + bannedAt: string; + expiresAt: string | null; + reason: string | null; +} + +/** Stream chat settings */ +export interface StreamChatSettings { + slowModeSeconds: number; + followerOnlyChat: boolean; + linkBlocking: boolean; +} From ddd4e6e50a1f3469d39050ef8fa0a4f15da1e08c Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:21:54 -0600 Subject: [PATCH 02/26] chore: fix code formatting for CI --- app/api/streams/settings/route.ts | 10 ++++++++-- .../stream-manager/ChatModerationSettings.tsx | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/api/streams/settings/route.ts b/app/api/streams/settings/route.ts index 1f5ba3e1..ba8e4bc0 100644 --- a/app/api/streams/settings/route.ts +++ b/app/api/streams/settings/route.ts @@ -7,7 +7,10 @@ export async function PATCH(req: NextRequest) { await req.json(); if (!wallet) { - return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); } // Validate slow mode value @@ -92,7 +95,10 @@ export async function GET(req: Request) { const wallet = searchParams.get("wallet"); if (!wallet) { - return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); } const result = await sql` diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx index 47e448e1..4ed9e822 100644 --- a/components/dashboard/stream-manager/ChatModerationSettings.tsx +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -203,7 +203,9 @@ export default function ChatModerationSettings() { updateSetting("linkBlocking", e.target.checked)} + onChange={e => + updateSetting("linkBlocking", e.target.checked) + } disabled={isSaving} className="w-4 h-4 rounded border-border bg-secondary text-highlight focus:ring-1 focus:ring-highlight disabled:opacity-50" /> From c78b481ecefe2f91e194b56c5b02f172aa1f971e Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:28:51 -0600 Subject: [PATCH 03/26] fix: add required curly braces for if statements --- .../dashboard/stream-manager/ChatModerationSettings.tsx | 8 ++++++-- components/stream/chat-section.tsx | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx index 4ed9e822..d9497233 100644 --- a/components/dashboard/stream-manager/ChatModerationSettings.tsx +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -63,7 +63,9 @@ export default function ChatModerationSettings() { key: keyof StreamChatSettings, value: number | boolean ) => { - if (!walletAddress) return; + if (!walletAddress) { + return; + } setIsSaving(true); try { @@ -88,7 +90,9 @@ export default function ChatModerationSettings() { }; const unbanUser = async (username: string) => { - if (!walletAddress) return; + if (!walletAddress) { + return; + } try { const res = await fetch( diff --git a/components/stream/chat-section.tsx b/components/stream/chat-section.tsx index 2e50c112..192c21a2 100644 --- a/components/stream/chat-section.tsx +++ b/components/stream/chat-section.tsx @@ -77,7 +77,9 @@ const ChatSection = ({ messageId: number, username: string ) => { - if (!isStreamOwner) return; + if (!isStreamOwner) { + return; + } e.preventDefault(); setContextMenu({ messageId, From 90ceb736633311f9ba50cd1e09c934ac86c3b112 Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:38:01 -0600 Subject: [PATCH 04/26] test: update chat API tests for moderation enforcement - Add mocks for permanent ban check - Add mocks for timeout check - Add mocks for slow mode validation - Update combined lookup to include moderation settings --- app/api/streams/chat/__tests__/route.test.ts | 28 ++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/api/streams/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index c05aaef7..1de46a27 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -144,16 +144,23 @@ describe("POST /api/streams/chat", () => { it("returns 201 and chatMessage on success", async () => { sqlMock .mockResolvedValueOnce({ - // combined lookup + // combined lookup with moderation settings rows: [ { sender_id: 1, sender_username: "Alice", + streamer_username: "Bob", is_live: true, session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, }, ], }) + .mockResolvedValueOnce({ rows: [] }) // permanent ban check + .mockResolvedValueOnce({ rows: [] }) // timeout check + .mockResolvedValueOnce({ rows: [] }) // slow mode last message check .mockResolvedValueOnce({ // INSERT rows: [{ id: 99, created_at: "2025-01-01T00:00:00Z" }], @@ -176,7 +183,24 @@ describe("POST /api/streams/chat", () => { }); it("returns 500 on unexpected database error", async () => { - sqlMock.mockRejectedValueOnce(new Error("DB down")); + sqlMock + .mockResolvedValueOnce({ + // combined lookup succeeds + rows: [ + { + sender_id: 1, + sender_username: "Alice", + streamer_username: "Bob", + is_live: true, + session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, + }, + ], + }) + .mockRejectedValueOnce(new Error("DB down")); // ban check fails + const req = makeRequest("POST", { wallet: "0xABC", playbackId: "pb1", From 466c282837961229f4a3fa53a4dd1a86f9d15bde Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:42:00 -0600 Subject: [PATCH 05/26] test: fix mock count for chat moderation - Remove slow mode mock when slow_mode_seconds is 0 - Add streamer_id to combined lookup mock --- app/api/streams/chat/__tests__/route.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/streams/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index 1de46a27..b4e7909f 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -149,6 +149,7 @@ describe("POST /api/streams/chat", () => { { sender_id: 1, sender_username: "Alice", + streamer_id: 2, streamer_username: "Bob", is_live: true, session_id: 10, @@ -160,7 +161,6 @@ describe("POST /api/streams/chat", () => { }) .mockResolvedValueOnce({ rows: [] }) // permanent ban check .mockResolvedValueOnce({ rows: [] }) // timeout check - .mockResolvedValueOnce({ rows: [] }) // slow mode last message check .mockResolvedValueOnce({ // INSERT rows: [{ id: 99, created_at: "2025-01-01T00:00:00Z" }], @@ -190,6 +190,7 @@ describe("POST /api/streams/chat", () => { { sender_id: 1, sender_username: "Alice", + streamer_id: 2, streamer_username: "Bob", is_live: true, session_id: 10, From 204a933055468f64f618e36a22773590823bec2c Mon Sep 17 00:00:00 2001 From: "Depo.dev" Date: Wed, 25 Mar 2026 21:47:53 +0100 Subject: [PATCH 06/26] feat: add realtime notifications system --- app/api/streams/access/check/route.ts | 9 + app/api/streams/access/password/route.ts | 8 + app/api/streams/start/route.ts | 77 +-- app/api/tips/refresh-total/route.ts | 63 ++- app/api/users/follow/route.ts | 45 +- app/api/users/notifications/[id]/route.ts | 91 ++++ .../notifications/__tests__/routes.test.ts | 181 +++++++ .../users/notifications/preferences/route.ts | 70 +++ app/api/users/notifications/read-all/route.ts | 21 + app/api/users/notifications/route.ts | 90 +-- app/api/users/notifications/stream/route.ts | 90 +++ app/api/users/register/route.ts | 8 + app/api/users/updates/[wallet]/route.ts | 9 + app/api/webhooks/mux/route.ts | 212 +++++--- app/dashboard/layout.tsx | 3 +- app/dashboard/notifications/page.tsx | 5 + components/dashboard/common/Sidebar.tsx | 6 + .../notifications/NotificationListItem.tsx | 87 +++ .../notifications/NotificationsPage.tsx | 154 ++++++ .../notifications/NotificationSettings.tsx | 366 ++++++------- components/shared/NotificationBell.tsx | 180 +++--- components/templates/TipReceivedEmail.tsx | 131 +++++ .../add-realtime-notifications-system.sql | 73 +++ hooks/use-notifications.ts | 181 +++++++ lib/__tests__/notifications.test.ts | 42 ++ lib/notification-email.ts | 61 +++ lib/notification-utils.ts | 16 + lib/notifications.ts | 512 +++++++++++++++++- types/notifications.ts | 65 +++ types/user.ts | 5 + 30 files changed, 2297 insertions(+), 564 deletions(-) create mode 100644 app/api/streams/access/check/route.ts create mode 100644 app/api/streams/access/password/route.ts create mode 100644 app/api/users/notifications/[id]/route.ts create mode 100644 app/api/users/notifications/__tests__/routes.test.ts create mode 100644 app/api/users/notifications/preferences/route.ts create mode 100644 app/api/users/notifications/read-all/route.ts create mode 100644 app/api/users/notifications/stream/route.ts create mode 100644 app/dashboard/notifications/page.tsx create mode 100644 components/notifications/NotificationListItem.tsx create mode 100644 components/notifications/NotificationsPage.tsx create mode 100644 components/templates/TipReceivedEmail.tsx create mode 100644 db/migrations/add-realtime-notifications-system.sql create mode 100644 hooks/use-notifications.ts create mode 100644 lib/__tests__/notifications.test.ts create mode 100644 lib/notification-email.ts create mode 100644 lib/notification-utils.ts create mode 100644 types/notifications.ts diff --git a/app/api/streams/access/check/route.ts b/app/api/streams/access/check/route.ts new file mode 100644 index 00000000..c8d60ea1 --- /dev/null +++ b/app/api/streams/access/check/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ accessRequired: false }, { status: 200 }); +} + +export async function POST() { + return NextResponse.json({ accessRequired: false }, { status: 200 }); +} \ No newline at end of file diff --git a/app/api/streams/access/password/route.ts b/app/api/streams/access/password/route.ts new file mode 100644 index 00000000..dcc8b1a8 --- /dev/null +++ b/app/api/streams/access/password/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + return NextResponse.json( + { error: "Password-protected stream access is not configured." }, + { status: 501 } + ); +} \ No newline at end of file diff --git a/app/api/streams/start/route.ts b/app/api/streams/start/route.ts index 01d2546b..5996d070 100644 --- a/app/api/streams/start/route.ts +++ b/app/api/streams/start/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { getMuxStreamHealth } from "@/lib/mux/server"; import { verifySession } from "@/lib/auth/verify-session"; -import { writeNotification } from "@/lib/notifications"; +import { + createLiveNotificationsForFollowers, + withNotificationTransaction, +} from "@/lib/notifications"; export async function POST(req: NextRequest) { // Verify the caller is logged in @@ -46,40 +49,46 @@ export async function POST(req: NextRequest) { console.error("Stream health check failed:", healthError); } - const result = await sql` - UPDATE users SET - is_live = true, - stream_started_at = CURRENT_TIMESTAMP, - current_viewers = 0, - updated_at = CURRENT_TIMESTAMP - WHERE id = ${user.id} - RETURNING id, username, mux_stream_id, mux_playback_id - `; + const updatedUser = await withNotificationTransaction(async client => { + const result = await client.sql<{ + id: string; + username: string; + mux_stream_id: string; + mux_playback_id: string | null; + stream_started_at: Date; + }>` + UPDATE users SET + is_live = true, + stream_started_at = CURRENT_TIMESTAMP, + current_viewers = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} AND is_live = false + RETURNING id, username, mux_stream_id, mux_playback_id, stream_started_at + `; - const updatedUser = result.rows[0]; + const updated = result.rows[0]; + if (!updated) { + throw new Error("Stream is already live"); + } - try { - await sql` + await client.sql` INSERT INTO stream_sessions (user_id, mux_session_id, playback_id, started_at) - VALUES (${updatedUser.id}, ${updatedUser.mux_stream_id}, ${updatedUser.mux_playback_id}, CURRENT_TIMESTAMP) + SELECT ${updated.id}, ${updated.mux_stream_id}, ${updated.mux_playback_id}, ${updated.stream_started_at.toISOString()} + WHERE NOT EXISTS ( + SELECT 1 FROM stream_sessions WHERE user_id = ${updated.id} AND ended_at IS NULL + ) `; - } catch (sessionError) { - console.error("Failed to create stream session record:", sessionError); - } - // Fire-and-forget live notifications to all followers via join table - sql`SELECT follower_id FROM user_follows WHERE followee_id = ${updatedUser.id}` - .then(({ rows }) => { - for (const { follower_id } of rows) { - writeNotification( - follower_id, - "live", - `${updatedUser.username} is live!`, - `${updatedUser.username} just started streaming` - ).catch(() => {}); - } - }) - .catch(() => {}); + await createLiveNotificationsForFollowers({ + creatorId: updated.id, + creatorUsername: updated.username, + playbackId: updated.mux_playback_id, + dedupeKey: `stream-live:${updated.id}:${updated.stream_started_at.toISOString()}`, + client, + }); + + return updated; + }); return NextResponse.json( { @@ -89,13 +98,19 @@ export async function POST(req: NextRequest) { streamId: updatedUser.mux_stream_id, playbackId: updatedUser.mux_playback_id, username: updatedUser.username, - startedAt: new Date().toISOString(), + startedAt: updatedUser.stream_started_at.toISOString(), }, }, { status: 200 } ); } catch (error) { console.error("Stream start error:", error); + if (error instanceof Error && error.message === "Stream is already live") { + return NextResponse.json( + { error: "Stream is already live" }, + { status: 409 } + ); + } return NextResponse.json( { error: "Failed to start stream" }, { status: 500 } diff --git a/app/api/tips/refresh-total/route.ts b/app/api/tips/refresh-total/route.ts index 001f4cfb..4ee27e03 100644 --- a/app/api/tips/refresh-total/route.ts +++ b/app/api/tips/refresh-total/route.ts @@ -1,9 +1,20 @@ // app/api/tips/refresh-total/route.ts +import { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + createTipReceivedNotification, + withNotificationTransaction, +} from "@/lib/notifications"; import { fetchPaymentsReceived } from "@/lib/stellar/horizon"; -export async function POST(request: Request) { +export async function POST(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) { + return session.response; + } + try { const { username } = await request.json(); @@ -14,14 +25,11 @@ export async function POST(request: Request) { ); } - // TODO: Add authentication check here - // Verify that the requesting user is the owner or admin - // 1. Fetch user from database const userResult = await sql` - SELECT id, username, stellar_public_key + SELECT id, username, wallet AS stellar_public_key, last_tip_at FROM users - WHERE username = ${username} + WHERE LOWER(username) = LOWER(${username}) `; if (userResult.rows.length === 0) { @@ -29,6 +37,10 @@ export async function POST(request: Request) { } const user = userResult.rows[0]; + if (user.id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!user.stellar_public_key) { return NextResponse.json( { error: "User has not configured Stellar wallet" }, @@ -66,17 +78,38 @@ export async function POST(request: Request) { const totalCount = allTips.length; const lastTipAt = allTips.length > 0 ? allTips[0].timestamp : null; + const shouldCreateTipNotifications = Boolean(user.last_tip_at); + const newTips = shouldCreateTipNotifications + ? allTips.filter( + tip => + Date.parse(tip.timestamp) >= Date.parse(String(user.last_tip_at)) + ) + : []; // 4. Update database - await sql` - UPDATE users - SET - total_tips_received = ${totalReceived}, - total_tips_count = ${totalCount}, - last_tip_at = ${lastTipAt}, - updated_at = CURRENT_TIMESTAMP - WHERE id = ${user.id} - `; + await withNotificationTransaction(async client => { + await client.sql` + UPDATE users + SET + total_tips_received = ${totalReceived}, + total_tips_count = ${totalCount}, + last_tip_at = ${lastTipAt}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} + `; + + for (const tip of [...newTips].reverse()) { + await createTipReceivedNotification({ + userId: user.id, + amount: tip.amount, + senderLabel: tip.sender, + senderWallet: tip.sender, + txHash: tip.txHash, + paymentId: tip.id, + client, + }); + } + }); // 5. Return updated statistics return NextResponse.json({ diff --git a/app/api/users/follow/route.ts b/app/api/users/follow/route.ts index a15a494b..15a0092f 100644 --- a/app/api/users/follow/route.ts +++ b/app/api/users/follow/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { verifySession } from "@/lib/auth/verify-session"; import { createRateLimiter } from "@/lib/rate-limit"; -import { writeNotification } from "@/lib/notifications"; +import { + createNotification, + withNotificationTransaction, +} from "@/lib/notifications"; // 30 follow/unfollow actions per IP per minute const isRateLimited = createRateLimiter(60_000, 30); @@ -60,23 +63,31 @@ export async function POST(req: NextRequest) { } if (action === "follow") { - await sql` - INSERT INTO user_follows (follower_id, followee_id) - VALUES (${callerId}, ${receiverId}) - ON CONFLICT DO NOTHING - `; + await withNotificationTransaction(async client => { + const followResult = await client.sql` + INSERT INTO user_follows (follower_id, followee_id) + VALUES (${callerId}, ${receiverId}) + ON CONFLICT DO NOTHING + RETURNING follower_id + `; + + if ((followResult.rowCount ?? 0) === 0) { + return; + } - // Write notification — awaited so it completes before response is sent - try { - await writeNotification( - receiverId, - "follow", - "New follower", - `${callerUsername} started following you` - ); - } catch (notifErr) { - console.error("[follow] notification write failed:", notifErr); - } + await createNotification({ + userId: receiverId, + type: "new_follower", + title: "New follower", + body: `${callerUsername} started following you`, + metadata: { + followerId: callerId, + followerUsername: callerUsername, + url: `/${callerUsername}`, + }, + client, + }); + }); return NextResponse.json({ message: "Followed successfully" }); } else { diff --git a/app/api/users/notifications/[id]/route.ts b/app/api/users/notifications/[id]/route.ts new file mode 100644 index 00000000..d09699df --- /dev/null +++ b/app/api/users/notifications/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + deleteNotification, + markNotificationAsRead, +} from "@/lib/notifications"; + +const paramsSchema = z.object({ + id: z.string().uuid(), +}); + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const parsedParams = paramsSchema.safeParse(await params); + if (!parsedParams.success) { + return NextResponse.json( + { error: "Invalid notification id" }, + { status: 400 } + ); + } + + try { + const notification = await markNotificationAsRead( + session.userId, + parsedParams.data.id + ); + + if (!notification) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ notification }); + } catch (error) { + console.error("PATCH notification error:", error); + return NextResponse.json( + { error: "Failed to update notification" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const parsedParams = paramsSchema.safeParse(await params); + if (!parsedParams.success) { + return NextResponse.json( + { error: "Invalid notification id" }, + { status: 400 } + ); + } + + try { + const deleted = await deleteNotification( + session.userId, + parsedParams.data.id + ); + + if (!deleted) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ deleted: true }); + } catch (error) { + console.error("DELETE notification error:", error); + return NextResponse.json( + { error: "Failed to delete notification" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/users/notifications/__tests__/routes.test.ts b/app/api/users/notifications/__tests__/routes.test.ts new file mode 100644 index 00000000..be0e8298 --- /dev/null +++ b/app/api/users/notifications/__tests__/routes.test.ts @@ -0,0 +1,181 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("@/lib/notifications", () => ({ + listNotifications: jest.fn(), + markNotificationAsRead: jest.fn(), + markAllNotificationsAsRead: jest.fn(), + deleteNotification: jest.fn(), + getNotificationPreferences: jest.fn(), + updateNotificationPreferences: jest.fn(), +})); + +import { GET as getNotifications } from "../route"; +import { + PATCH as patchNotification, + DELETE as deleteNotificationRoute, +} from "../[id]/route"; +import { PATCH as patchReadAll } from "../read-all/route"; +import { + GET as getPreferences, + PUT as putPreferences, +} from "../preferences/route"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + deleteNotification, + getNotificationPreferences, + listNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, + updateNotificationPreferences, +} from "@/lib/notifications"; + +const verifySessionMock = verifySession as jest.Mock; +const listNotificationsMock = listNotifications as jest.Mock; +const markNotificationAsReadMock = markNotificationAsRead as jest.Mock; +const markAllNotificationsAsReadMock = markAllNotificationsAsRead as jest.Mock; +const deleteNotificationMock = deleteNotification as jest.Mock; +const getNotificationPreferencesMock = getNotificationPreferences as jest.Mock; +const updateNotificationPreferencesMock = updateNotificationPreferences as jest.Mock; + +const makeRequest = (method: string, path: string, body?: unknown) => + new Request(`http://localhost${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +describe("notifications routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("lists notifications for the authenticated user", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + listNotificationsMock.mockResolvedValue({ + notifications: [{ id: "n-1", title: "Test notification" }], + unreadCount: 2, + }); + + const response = await getNotifications( + makeRequest("GET", "/api/users/notifications?limit=5") + ); + + expect(response.status).toBe(200); + expect(listNotificationsMock).toHaveBeenCalledWith("user-1", 5); + await expect(response.json()).resolves.toEqual({ + notifications: [{ id: "n-1", title: "Test notification" }], + unreadCount: 2, + }); + }); + + it("rejects invalid notification list query params", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + + const response = await getNotifications( + makeRequest("GET", "/api/users/notifications?limit=500") + ); + + expect(response.status).toBe(400); + }); + + it("marks a single notification as read", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + markNotificationAsReadMock.mockResolvedValue({ id: "n-1", read: true }); + + const response = await patchNotification( + makeRequest("PATCH", "/api/users/notifications/11111111-1111-1111-1111-111111111111"), + { + params: Promise.resolve({ + id: "11111111-1111-1111-1111-111111111111", + }), + } + ); + + expect(response.status).toBe(200); + expect(markNotificationAsReadMock).toHaveBeenCalledWith( + "user-1", + "11111111-1111-1111-1111-111111111111" + ); + }); + + it("marks all notifications as read", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + markAllNotificationsAsReadMock.mockResolvedValue(4); + + const response = await patchReadAll( + makeRequest("PATCH", "/api/users/notifications/read-all") + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ updatedCount: 4 }); + }); + + it("deletes a notification", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + deleteNotificationMock.mockResolvedValue(true); + + const response = await deleteNotificationRoute( + makeRequest("DELETE", "/api/users/notifications/11111111-1111-1111-1111-111111111111"), + { + params: Promise.resolve({ + id: "11111111-1111-1111-1111-111111111111", + }), + } + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ deleted: true }); + }); + + it("returns notification preferences for the authenticated user", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + getNotificationPreferencesMock.mockResolvedValue({ + newFollower: true, + tipReceived: true, + streamLive: false, + recordingReady: true, + emailNotifications: false, + }); + + const response = await getPreferences( + makeRequest("GET", "/api/users/notifications/preferences") + ); + + expect(response.status).toBe(200); + expect(getNotificationPreferencesMock).toHaveBeenCalledWith("user-1"); + }); + + it("updates notification preferences", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + updateNotificationPreferencesMock.mockResolvedValue({ + newFollower: false, + tipReceived: true, + streamLive: true, + recordingReady: true, + emailNotifications: true, + }); + + const response = await putPreferences( + makeRequest("PUT", "/api/users/notifications/preferences", { + newFollower: false, + }) + ); + + expect(response.status).toBe(200); + expect(updateNotificationPreferencesMock).toHaveBeenCalledWith("user-1", { + newFollower: false, + }); + }); +}); \ No newline at end of file diff --git a/app/api/users/notifications/preferences/route.ts b/app/api/users/notifications/preferences/route.ts new file mode 100644 index 00000000..8a1aaabb --- /dev/null +++ b/app/api/users/notifications/preferences/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "@/lib/notifications"; + +const preferencesSchema = z.object({ + newFollower: z.boolean().optional(), + tipReceived: z.boolean().optional(), + streamLive: z.boolean().optional(), + recordingReady: z.boolean().optional(), + emailNotifications: z.boolean().optional(), +}); + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const preferences = await getNotificationPreferences(session.userId); + return NextResponse.json({ preferences }); + } catch (error) { + console.error("GET notification preferences error:", error); + return NextResponse.json( + { error: "Failed to fetch notification preferences" }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let payload: unknown; + + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = preferencesSchema.safeParse(payload); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid notification preferences" }, + { status: 400 } + ); + } + + try { + const preferences = await updateNotificationPreferences( + session.userId, + parsed.data + ); + return NextResponse.json({ preferences }); + } catch (error) { + console.error("PUT notification preferences error:", error); + return NextResponse.json( + { error: "Failed to update notification preferences" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/users/notifications/read-all/route.ts b/app/api/users/notifications/read-all/route.ts new file mode 100644 index 00000000..7c7f8e58 --- /dev/null +++ b/app/api/users/notifications/read-all/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; +import { markAllNotificationsAsRead } from "@/lib/notifications"; + +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const updatedCount = await markAllNotificationsAsRead(session.userId); + return NextResponse.json({ updatedCount }); + } catch (error) { + console.error("PATCH read-all error:", error); + return NextResponse.json( + { error: "Failed to mark notifications as read" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/users/notifications/route.ts b/app/api/users/notifications/route.ts index c3f84180..5336526a 100644 --- a/app/api/users/notifications/route.ts +++ b/app/api/users/notifications/route.ts @@ -1,96 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; -import { sql } from "@vercel/postgres"; +import { z } from "zod"; import { verifySession } from "@/lib/auth/verify-session"; -import { writeNotification } from "@/lib/notifications"; +import { listNotifications } from "@/lib/notifications"; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}); -// ─── GET — fetch caller's notifications ────────────────────────────────────── export async function GET(req: NextRequest) { const session = await verifySession(req); if (!session.ok) { return session.response; } - try { - const { rows } = await sql` - SELECT COALESCE(notifications, ARRAY[]::jsonb[]) AS notifications - FROM users - WHERE id = ${session.userId} - `; - - if (rows.length === 0) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - // notifications is JSONB[] — already parsed by @vercel/postgres into an array - const raw: Record[] = rows[0].notifications ?? []; - - // Newest-first, cap at 50 - const notifications = [...raw].reverse().slice(0, 50); - const unreadCount = notifications.filter(n => n.read === false).length; + const parsedQuery = querySchema.safeParse( + Object.fromEntries(new URL(req.url).searchParams.entries()) + ); - return NextResponse.json({ notifications, unreadCount }); - } catch (error) { - console.error("GET notifications error:", error); + if (!parsedQuery.success) { return NextResponse.json( - { error: "Failed to fetch notifications" }, - { status: 500 } - ); - } -} - -// ─── POST — internal server-to-server write only ───────────────────────────── -export async function POST(req: NextRequest) { - const internalSecret = process.env.INTERNAL_API_SECRET; - if ( - !internalSecret || - req.headers.get("x-internal-secret") !== internalSecret - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { recipientId, type, title, text } = await req.json(); - - if (!recipientId || !type || !title || !text) { - return NextResponse.json( - { error: "Missing required fields: recipientId, type, title, text" }, + { error: "Invalid query parameters" }, { status: 400 } ); } try { - await writeNotification(recipientId, type, title, text); - return NextResponse.json({ message: "Notification added" }); - } catch (error) { - console.error("POST notification error:", error); - return NextResponse.json( - { error: "Failed to add notification" }, - { status: 500 } + const { notifications, unreadCount } = await listNotifications( + session.userId, + parsedQuery.data.limit ?? 50 ); - } -} - -// ─── PATCH — mark all as read for caller ───────────────────────────────────── -export async function PATCH(req: NextRequest) { - const session = await verifySession(req); - if (!session.ok) { - return session.response; - } - - try { - await sql` - UPDATE users - SET notifications = ARRAY( - SELECT jsonb_set(n::jsonb, '{read}', 'true'::jsonb) - FROM unnest(COALESCE(notifications, ARRAY[]::jsonb[])) AS t(n) - ) - WHERE id = ${session.userId} - `; - return NextResponse.json({ message: "All notifications marked as read" }); + return NextResponse.json({ notifications, unreadCount }); } catch (error) { - console.error("PATCH notifications error:", error); + console.error("GET notifications error:", error); return NextResponse.json( - { error: "Failed to mark notifications as read" }, + { error: "Failed to fetch notifications" }, { status: 500 } ); } diff --git a/app/api/users/notifications/stream/route.ts b/app/api/users/notifications/stream/route.ts new file mode 100644 index 00000000..67b31804 --- /dev/null +++ b/app/api/users/notifications/stream/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; +import { listUnreadNotificationsSince } from "@/lib/notifications"; +import type { NotificationCursor } from "@/types/notifications"; + +const encoder = new TextEncoder(); +const initialCursorId = "00000000-0000-0000-0000-000000000000"; + +function encodeSseChunk(payload: string) { + return encoder.encode(payload); +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + let cursor: NotificationCursor = { + createdAt: new Date().toISOString(), + id: initialCursorId, + }; + + const close = () => { + if (closed) { + return; + } + + closed = true; + clearInterval(intervalId); + req.signal.removeEventListener("abort", close); + controller.close(); + }; + + const poll = async () => { + try { + const notifications = await listUnreadNotificationsSince( + session.userId, + cursor, + 50 + ); + + if (notifications.length === 0) { + controller.enqueue(encodeSseChunk(": keep-alive\n\n")); + return; + } + + for (const notification of notifications) { + controller.enqueue( + encodeSseChunk(`data: ${JSON.stringify(notification)}\n\n`) + ); + } + + const lastNotification = notifications[notifications.length - 1]; + cursor = { + createdAt: lastNotification.createdAt, + id: lastNotification.id, + }; + } catch (error) { + console.error("Notification SSE polling error:", error); + controller.enqueue( + encodeSseChunk( + `event: error\ndata: ${JSON.stringify({ message: "Polling failed" })}\n\n` + ) + ); + } + }; + + controller.enqueue(encodeSseChunk(": connected\n\n")); + void poll(); + + const intervalId = setInterval(() => { + void poll(); + }, 5_000); + + req.signal.addEventListener("abort", close); + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} \ No newline at end of file diff --git a/app/api/users/register/route.ts b/app/api/users/register/route.ts index 03e6daac..b7280098 100644 --- a/app/api/users/register/route.ts +++ b/app/api/users/register/route.ts @@ -135,6 +135,7 @@ async function handler(req: NextRequest) { wallet, socialLinks, emailNotifications, + notification_preferences, creator, mux_stream_id, mux_playback_id, @@ -147,6 +148,13 @@ async function handler(req: NextRequest) { ${wallet}, ${JSON.stringify(socialLinks)}, ${emailNotifications}, + ${JSON.stringify({ + newFollower: true, + tipReceived: true, + streamLive: true, + recordingReady: true, + emailNotifications, + })}::jsonb, ${JSON.stringify(creator)}, ${muxStream?.id ?? null}, ${muxStream?.playbackId ?? null}, diff --git a/app/api/users/updates/[wallet]/route.ts b/app/api/users/updates/[wallet]/route.ts index be28a996..a8c51cc7 100644 --- a/app/api/users/updates/[wallet]/route.ts +++ b/app/api/users/updates/[wallet]/route.ts @@ -167,6 +167,15 @@ export async function PUT( sociallinks = ${processedSocialLinks}, emailverified = ${emailVerified}, emailnotifications = ${emailNotifications}, + notification_preferences = jsonb_set( + COALESCE( + notification_preferences, + '{"newFollower":true,"tipReceived":true,"streamLive":true,"recordingReady":true,"emailNotifications":true}'::jsonb + ), + '{emailNotifications}', + to_jsonb(${emailNotifications}), + true + ), creator = ${creator ? JSON.stringify(creator) : user.creator}, enable_recording = ${enableRecording}, updated_at = CURRENT_TIMESTAMP diff --git a/app/api/webhooks/mux/route.ts b/app/api/webhooks/mux/route.ts index 45a07f8f..5c113526 100644 --- a/app/api/webhooks/mux/route.ts +++ b/app/api/webhooks/mux/route.ts @@ -1,6 +1,11 @@ import { createHmac, timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { + createLiveNotificationsForFollowers, + createRecordingReadyNotification, + withNotificationTransaction, +} from "@/lib/notifications"; /** * Mux Webhook Handler @@ -104,54 +109,70 @@ export async function POST(req: Request) { case "video.live_stream.active": { console.log(`🔴 Stream ACTIVE (broadcasting): ${streamId}`); - await sql` - UPDATE users SET - is_live = true, - stream_started_at = CURRENT_TIMESTAMP, - current_viewers = 0, - updated_at = CURRENT_TIMESTAMP - WHERE mux_stream_id = ${streamId} - `; + await withNotificationTransaction(async client => { + const userResult = await client.sql<{ + id: string; + is_live: boolean; + mux_playback_id: string | null; + creator: { title?: string; streamTitle?: string } | null; + }>` + SELECT id, is_live, mux_playback_id, creator + FROM users + WHERE mux_stream_id = ${streamId} + LIMIT 1 + `; - // Create stream session record — only one per broadcast - try { - const userResult = await sql` - SELECT id, mux_playback_id, creator FROM users WHERE mux_stream_id = ${streamId} + const user = userResult.rows[0]; + if (!user) { + console.warn("⚠️ No user found for Mux stream", streamId); + return; + } + + const updateResult = await client.sql<{ + id: string; + username: string; + mux_playback_id: string | null; + stream_started_at: Date; + }>` + UPDATE users SET + is_live = true, + stream_started_at = COALESCE(stream_started_at, CURRENT_TIMESTAMP), + current_viewers = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} + RETURNING id, username, mux_playback_id, stream_started_at `; - if (userResult.rows.length > 0) { - const user = userResult.rows[0]; + const updatedUser = updateResult.rows[0]; + const existingSession = await client.sql` + SELECT id FROM stream_sessions WHERE user_id = ${user.id} AND ended_at IS NULL LIMIT 1 + `; - // Dedup: skip if an active session already exists - const existingSession = await sql` - SELECT id FROM stream_sessions WHERE user_id = ${user.id} AND ended_at IS NULL LIMIT 1 - `; + if (existingSession.rows.length === 0) { + const streamTitle = + user.creator?.title || + user.creator?.streamTitle || + "Live Stream"; - if (existingSession.rows.length === 0) { - const streamTitle = - user.creator?.title || - user.creator?.streamTitle || - "Live Stream"; + await client.sql` + INSERT INTO stream_sessions (user_id, title, playback_id, started_at, mux_session_id) + VALUES (${user.id}, ${streamTitle}, ${user.mux_playback_id}, ${updatedUser.stream_started_at.toISOString()}, ${streamId}) + `; + console.log("✅ New stream session created"); + } else { + console.log("⏭️ Active session already exists, skipping creation"); + } - await sql` - INSERT INTO stream_sessions (user_id, title, playback_id, started_at, mux_session_id) - VALUES (${user.id}, ${streamTitle}, ${user.mux_playback_id}, CURRENT_TIMESTAMP, ${streamId}) - `; - console.log("✅ New stream session created"); - } else { - console.log( - "⏭️ Active session already exists, skipping creation" - ); - } + if (!user.is_live) { + await createLiveNotificationsForFollowers({ + creatorId: updatedUser.id, + creatorUsername: updatedUser.username, + playbackId: updatedUser.mux_playback_id, + dedupeKey: `stream-live:${updatedUser.id}:${updatedUser.stream_started_at.toISOString()}`, + client, + }); } - } catch (sessionError) { - console.error( - "⚠️ Failed to create stream session (non-critical):", - sessionError instanceof Error - ? sessionError.message - : String(sessionError) - ); - } + }); console.log(`✅ Stream marked as LIVE`); break; @@ -234,60 +255,71 @@ export async function POST(req: Request) { } try { - let userId: string | null = null; - let streamSessionId: string | null = null; - let sessionTitle: string | null = "Stream Recording"; - - if (liveStreamId) { - const userResult = await sql` - SELECT id, mux_playback_id, creator FROM users WHERE mux_stream_id = ${liveStreamId} - `; - if (userResult.rows.length > 0) { - const u = userResult.rows[0]; - userId = u.id; - sessionTitle = - u.creator?.streamTitle ?? u.creator?.title ?? sessionTitle; - const sessionResult = await sql` - SELECT id FROM stream_sessions - WHERE user_id = ${u.id} AND ended_at IS NOT NULL - ORDER BY ended_at DESC LIMIT 1 + await withNotificationTransaction(async client => { + let userId: string | null = null; + let streamSessionId: string | null = null; + let sessionTitle: string | null = "Stream Recording"; + + if (liveStreamId) { + const userResult = await client.sql<{ + id: string; + creator: { streamTitle?: string; title?: string } | null; + }>` + SELECT id, creator FROM users WHERE mux_stream_id = ${liveStreamId} `; - if (sessionResult.rows.length > 0) { - streamSessionId = sessionResult.rows[0].id; + if (userResult.rows.length > 0) { + const u = userResult.rows[0]; + userId = u.id; + sessionTitle = + u.creator?.streamTitle ?? u.creator?.title ?? sessionTitle; + const sessionResult = await client.sql<{ id: string }>` + SELECT id FROM stream_sessions + WHERE user_id = ${u.id} AND ended_at IS NOT NULL + ORDER BY ended_at DESC LIMIT 1 + `; + if (sessionResult.rows.length > 0) { + streamSessionId = sessionResult.rows[0].id; + } } } - } - if (!userId) { - console.warn( - "⚠️ video.asset.ready: could not resolve user for asset", - assetId - ); - break; - } + if (!userId) { + console.warn( + "⚠️ video.asset.ready: could not resolve user for asset", + assetId + ); + return; + } + + await client.sql` + INSERT INTO stream_recordings ( + user_id, stream_session_id, mux_asset_id, playback_id, + title, duration, status, needs_review + ) + VALUES ( + ${userId}, + ${streamSessionId}, + ${assetId}, + ${playbackId}, + ${sessionTitle}, + ${duration ?? 0}, + 'ready', + true + ) + ON CONFLICT (mux_asset_id) DO UPDATE SET + status = 'ready', + duration = COALESCE(EXCLUDED.duration, stream_recordings.duration) + `; + + await createRecordingReadyNotification({ + userId, + title: "Recording ready", + recordingId: assetId, + playbackId, + client, + }); + }); - // Insert new recording with needs_review=true so the owner is prompted. - // ON CONFLICT: update status/duration only — preserve needs_review in case - // the user already dismissed or deleted the prompt. - await sql` - INSERT INTO stream_recordings ( - user_id, stream_session_id, mux_asset_id, playback_id, - title, duration, status, needs_review - ) - VALUES ( - ${userId}, - ${streamSessionId}, - ${assetId}, - ${playbackId}, - ${sessionTitle}, - ${duration ?? 0}, - 'ready', - true - ) - ON CONFLICT (mux_asset_id) DO UPDATE SET - status = 'ready', - duration = COALESCE(EXCLUDED.duration, stream_recordings.duration) - `; console.log(`✅ Stream recording saved: ${assetId}`); } catch (recErr) { console.error("❌ Failed to save stream recording:", recErr); diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index e18ca422..d1d690fc 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -8,11 +8,12 @@ import { ReactNode } from "react"; import ProtectedRoute from "@/components/auth/ProtectedRoute"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { HomeIcon, BarChart2, Video, Coins, Settings } from "lucide-react"; +import { HomeIcon, BarChart2, Video, Coins, Settings, Bell } from "lucide-react"; const mobileNavItems = [ { name: "Home", icon: HomeIcon, path: "/dashboard/home" }, { name: "Stream", icon: BarChart2, path: "/dashboard/stream-manager" }, + { name: "Alerts", icon: Bell, path: "/dashboard/notifications" }, { name: "Recordings", icon: Video, path: "/dashboard/recordings" }, { name: "Wallet", icon: Coins, path: "/dashboard/payout" }, { name: "Settings", icon: Settings, path: "/dashboard/settings" }, diff --git a/app/dashboard/notifications/page.tsx b/app/dashboard/notifications/page.tsx new file mode 100644 index 00000000..628c1195 --- /dev/null +++ b/app/dashboard/notifications/page.tsx @@ -0,0 +1,5 @@ +import NotificationsPage from "@/components/notifications/NotificationsPage"; + +export default function DashboardNotificationsPage() { + return ; +} \ No newline at end of file diff --git a/components/dashboard/common/Sidebar.tsx b/components/dashboard/common/Sidebar.tsx index 07d616fc..0f5d9c2e 100644 --- a/components/dashboard/common/Sidebar.tsx +++ b/components/dashboard/common/Sidebar.tsx @@ -8,6 +8,7 @@ import { Settings, BarChartIcon as ChartColumnDecreasing, ArrowLeftToLine, + Bell, Video, Coins, } from "lucide-react"; @@ -37,6 +38,11 @@ export default function Sidebar({ icon: , path: "/dashboard/stream-manager", }, + { + name: "Notifications", + icon: , + path: "/dashboard/notifications", + }, { name: "Recordings", icon: