diff --git a/database/migrations/008_notifications.sql b/database/migrations/008_notifications.sql new file mode 100644 index 0000000..616b4d5 --- /dev/null +++ b/database/migrations/008_notifications.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + link TEXT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE; + +CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + lead_assigned BOOLEAN NOT NULL DEFAULT TRUE, + lead_status BOOLEAN NOT NULL DEFAULT TRUE, + note_added BOOLEAN NOT NULL DEFAULT FALSE, + daily_digest BOOLEAN NOT NULL DEFAULT FALSE, + weekly_report BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 19380f0..c5d75d0 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -10,6 +10,24 @@ \echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ===' \i 002_seed.sql +\echo '=== Running 003_chat.sql (Chat Tables) ===' +\i 003_chat.sql + +\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ===' +\i 004_avatar_url.sql + +\echo '=== Running 005_last_read_at.sql (Last Read At Column) ===' +\i 005_last_read_at.sql + +\echo '=== Running 006_test_leads.sql (Test Lead Data) ===' +\i 006_test_leads.sql + +\echo '=== Running 007_ai_features.sql (AI Features) ===' +\i 007_ai_features.sql + +\echo '=== Running 008_notifications.sql (Notifications + Preferences) ===' +\i 008_notifications.sql + \echo '=== Migration Complete ===' \echo '' \echo 'Test accounts created:' diff --git a/package-lock.json b/package-lock.json index 7f6eddc..03119b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3297,7 +3297,6 @@ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, - "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..f6404f8 --- /dev/null +++ b/src/app/api/notifications/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const result = await query( + `UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Notification not found" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notification PATCH error:", error) + return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const result = await query( + `DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`, + [id, user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Notification not found" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notification DELETE error:", error) + return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 }) + } +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..dd1682a --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT lead_assigned, lead_status, note_added, daily_digest, weekly_report + FROM notification_preferences + WHERE user_id = $1`, + [user.id], + ) + + if (result.rowCount === 0) { + return NextResponse.json({ + leadAssigned: true, + leadStatus: true, + noteAdded: false, + dailyDigest: false, + weeklyReport: true, + }) + } + + const r = result.rows[0] + return NextResponse.json({ + leadAssigned: r.lead_assigned, + leadStatus: r.lead_status, + noteAdded: r.note_added, + dailyDigest: r.daily_digest, + weeklyReport: r.weekly_report, + }) + } catch (error) { + console.error("Preferences GET error:", error) + return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await request.json() + + await query( + `INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4, + daily_digest = $5, weekly_report = $6, updated_at = NOW()`, + [ + user.id, + body.leadAssigned ?? true, + body.leadStatus ?? true, + body.noteAdded ?? false, + body.dailyDigest ?? false, + body.weeklyReport ?? true, + ], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Preferences PATCH error:", error) + return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 }) + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..d8c2c00 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT id, type, title, description, link, is_read, created_at + FROM notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 50`, + [user.id], + ) + + const notifications = result.rows.map((r: any) => ({ + id: r.id, + type: r.type, + title: r.title, + description: r.description, + link: r.link, + read: r.is_read, + timestamp: r.created_at, + })) + + const unreadResult = await query( + `SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`, + [user.id], + ) + + return NextResponse.json({ + notifications, + unreadCount: parseInt(unreadResult.rows[0].count, 10), + }) + } catch (error) { + console.error("Notifications GET error:", error) + return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { type, title, description, link, userId } = await request.json() + const targetUserId = userId || user.id + + const result = await query( + `INSERT INTO notifications (user_id, type, title, description, link) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, type, title, description, link, is_read, created_at`, + [targetUserId, type, title, description || null, link || null], + ) + + const notif = result.rows[0] + return NextResponse.json({ + id: notif.id, + type: notif.type, + title: notif.title, + description: notif.description, + link: notif.link, + read: notif.is_read, + timestamp: notif.created_at, + }, { status: 201 }) + } catch (error) { + console.error("Notifications POST error:", error) + return NextResponse.json({ error: "Failed to create notification" }, { status: 500 }) + } +} + +export async function PATCH() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + await query( + `UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`, + [user.id], + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Notifications PATCH error:", error) + return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 }) + } +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index b28720c..f1bbebb 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -134,7 +134,7 @@ export function Topbar({ onMenuClick }: TopbarProps) { > - 3 + {unreadCount} diff --git a/src/components/settings/notification-settings.tsx b/src/components/settings/notification-settings.tsx index 80d092c..60d2cfc 100644 --- a/src/components/settings/notification-settings.tsx +++ b/src/components/settings/notification-settings.tsx @@ -1,45 +1,82 @@ "use client" +import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" import { toast } from "sonner" +interface Preferences { + leadAssigned: boolean + leadStatus: boolean + noteAdded: boolean + dailyDigest: boolean + weeklyReport: boolean +} + +const defaultPreferences: Preferences = { + leadAssigned: true, + leadStatus: true, + noteAdded: false, + dailyDigest: false, + weeklyReport: true, +} + const notifications = [ - { - id: "lead-assigned", - title: "Lead Assigned", - description: "When a lead is assigned to you", - defaultChecked: true, - }, - { - id: "lead-status", - title: "Status Changes", - description: "When a lead's status is updated", - defaultChecked: true, - }, - { - id: "note-added", - title: "Note Added", - description: "When a note is added to your lead", - defaultChecked: false, - }, - { - id: "daily-digest", - title: "Daily Digest", - description: "Receive a daily summary of lead activity", - defaultChecked: false, - }, - { - id: "weekly-report", - title: "Weekly Report", - description: "Receive a weekly performance report", - defaultChecked: true, - }, + { id: "leadAssigned", title: "Lead Assigned", description: "When a lead is assigned to you" }, + { id: "leadStatus", title: "Status Changes", description: "When a lead's status is updated" }, + { id: "noteAdded", title: "Note Added", description: "When a note is added to your lead" }, + { id: "dailyDigest", title: "Daily Digest", description: "Receive a daily summary of lead activity" }, + { id: "weeklyReport", title: "Weekly Report", description: "Receive a weekly performance report" }, ] export function NotificationSettings() { + const [prefs, setPrefs] = useState(defaultPreferences) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + async function load() { + try { + const res = await fetch("/api/notifications/preferences") + if (res.ok) { + const data = await res.json() + setPrefs(data) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + load() + }, []) + + async function handleSave() { + setSaving(true) + try { + const res = await fetch("/api/notifications/preferences", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }) + if (res.ok) { + toast.success("Notification preferences saved") + } else { + toast.error("Failed to save preferences") + } + } catch { + toast.error("Failed to save preferences") + } finally { + setSaving(false) + } + } + + function toggle(key: keyof Preferences) { + setPrefs((prev) => ({ ...prev, [key]: !prev[key] })) + } + return ( @@ -60,11 +97,18 @@ export function NotificationSettings() { {n.description} - + toggle(n.id as keyof Preferences)} + /> ))} - toast.success("Notification preferences saved")}>Save Preferences + + {saving ? "Saving..." : "Save Preferences"} + diff --git a/src/providers/notification-provider.tsx b/src/providers/notification-provider.tsx index 2275e51..fb28489 100644 --- a/src/providers/notification-provider.tsx +++ b/src/providers/notification-provider.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, useState, useCallback, useMemo, useEffect, ReactNode } from "react" import { Notification, NotificationType } from "@/types" -import { leads } from "@/data/leads" interface NotificationContextValue { notifications: Notification[] @@ -16,62 +15,29 @@ interface NotificationContextValue { const NotificationContext = createContext(null) -function generateId(): string { - return `notif-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` -} - -function seedInitialNotifications(): Notification[] { - const result: Notification[] = [] - - const recentLeads = leads.slice(0, 5) - recentLeads.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_created", - title: "New Lead Created", - description: `${lead.companyName} — ${lead.contactName}`, - timestamp: lead.createdAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - const statusChanged = leads.filter((l) => l.status !== "open").slice(0, 3) - statusChanged.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_status_changed", - title: "Lead Status Updated", - description: `${lead.companyName} moved to ${lead.status}`, - timestamp: lead.updatedAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - const assignedLeads = leads.filter((l) => l.assignedUser).slice(0, 3) - assignedLeads.forEach((lead) => { - result.push({ - id: generateId(), - type: "lead_assigned", - title: "Lead Assigned", - description: `${lead.companyName} assigned to ${lead.assignedUser!.name}`, - timestamp: lead.updatedAt, - read: false, - link: `/leads/${lead.id}`, - }) - }) - - result.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - - return result -} - export function NotificationProvider({ children }: { children: ReactNode }) { - const [notifications, setNotifications] = useState(seedInitialNotifications) - + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(true) const [unreadChatCount, setUnreadChatCount] = useState(0) + useEffect(() => { + async function fetchNotifications() { + try { + const res = await fetch("/api/notifications") + if (!res.ok) return + const data = await res.json() + if (data.notifications) { + setNotifications(data.notifications) + } + } catch { + // ignore + } finally { + setLoading(false) + } + } + fetchNotifications() + }, []) + useEffect(() => { let cancelled = false async function fetchUnread() { @@ -99,9 +65,9 @@ export function NotificationProvider({ children }: { children: ReactNode }) { ) const addNotification = useCallback( - (type: NotificationType, title: string, description: string, link?: string) => { + async (type: NotificationType, title: string, description: string, link?: string) => { const notif: Notification = { - id: generateId(), + id: `temp-${Date.now()}`, type, title, description, @@ -110,22 +76,48 @@ export function NotificationProvider({ children }: { children: ReactNode }) { link, } setNotifications((prev) => [notif, ...prev]) + try { + await fetch("/api/notifications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, title, description, link }), + }) + } catch { + // ignore + } }, [] ) - const markAsRead = useCallback((id: string) => { + const markAsRead = useCallback(async (id: string) => { + if (id.startsWith("temp-")) return setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)) ) + try { + await fetch(`/api/notifications/${id}`, { method: "PATCH" }) + } catch { + // ignore + } }, []) - const markAllAsRead = useCallback(() => { + const markAllAsRead = useCallback(async () => { setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + try { + await fetch("/api/notifications", { method: "PATCH" }) + } catch { + // ignore + } }, []) - const dismiss = useCallback((id: string) => { + const dismiss = useCallback(async (id: string) => { setNotifications((prev) => prev.filter((n) => n.id !== id)) + if (id.startsWith("temp-")) return + try { + await fetch(`/api/notifications/${id}`, { method: "DELETE" }) + } catch { + // ignore + } }, []) return (
{n.description}