From 8761482e78de225c7240449e8ff7995ec822fc3a Mon Sep 17 00:00:00 2001 From: Pri_ss_ca <136065253+prissca@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:57:45 +0000 Subject: [PATCH] build role-based activity feed --- app/api/activity/route.ts | 95 ++++++++ app/api/notifications/route.ts | 8 +- app/dashboard/admin/activity/page.tsx | 13 ++ app/dashboard/driver/activity/page.tsx | 5 + app/dashboard/driver/notifications/page.tsx | 215 +----------------- app/dashboard/investor/activity/page.tsx | 5 + components/dashboard/activity-feed.tsx | 206 +++++++++++++++++ components/dashboard/activity-unread-bell.tsx | 69 ++++++ components/dashboard/header.tsx | 24 +- .../investor-overview/dashboard-header.tsx | 24 +- components/dashboard/role-activity-page.tsx | 40 ++++ components/dashboard/sidebar.tsx | 4 + lib/activity.ts | 46 ++++ lib/services/activity.service.ts | 22 ++ models/Notification.ts | 7 + 15 files changed, 541 insertions(+), 242 deletions(-) create mode 100644 app/api/activity/route.ts create mode 100644 app/dashboard/admin/activity/page.tsx create mode 100644 app/dashboard/driver/activity/page.tsx create mode 100644 app/dashboard/investor/activity/page.tsx create mode 100644 components/dashboard/activity-feed.tsx create mode 100644 components/dashboard/activity-unread-bell.tsx create mode 100644 components/dashboard/role-activity-page.tsx create mode 100644 lib/activity.ts create mode 100644 lib/services/activity.service.ts diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts new file mode 100644 index 0000000..e94b67c --- /dev/null +++ b/app/api/activity/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { ACTIVITY_CATEGORIES, inferActivityCategory, type ActivityCategory } from "@/lib/activity" +import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { parseJsonBody, parseSearchParams } from "@/lib/api/validation" +import dbConnect from "@/lib/dbConnect" +import Notification from "@/models/Notification" + +const querySchema = z.object({ + category: z.enum(ACTIVITY_CATEGORIES).optional(), + unread: z.enum(["true", "false"]).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) + +const updateSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("mark-all-read") }), + z.object({ + action: z.literal("set-read"), + activityId: z.string().regex(/^[a-f\d]{24}$/i, "Invalid activityId."), + read: z.boolean(), + }), +]) + +function serializeActivity(notification: any) { + const storedCategory = notification.category as ActivityCategory | undefined + return { + id: notification._id.toString(), + title: notification.title, + message: notification.message, + category: storedCategory || inferActivityCategory(notification.type), + priority: notification.priority, + link: notification.link, + read: notification.read, + timestamp: notification.timestamp.toISOString(), + } +} + +export async function GET(request: Request) { + try { + const auth = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) + if ("response" in auth) return auth.response + + const query = parseSearchParams(request, querySchema) + if ("response" in query) return query.response + + await dbConnect() + const filter: Record = { userId: auth.user._id.toString() } + if (query.data.category) filter.category = query.data.category + if (query.data.unread) filter.read = query.data.unread === "true" + + const [notifications, unreadCount] = await Promise.all([ + Notification.find(filter).sort({ timestamp: -1 }).limit(query.data.limit).lean(), + Notification.countDocuments({ userId: auth.user._id.toString(), read: false }), + ]) + + const response = NextResponse.json({ + activities: notifications.map(serializeActivity), + unreadCount, + }) + return finalizeAuthenticatedResponse(response, auth) + } catch (error) { + console.error("ACTIVITY_GET_ERROR", error) + return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 }) + } +} + +export async function PATCH(request: Request) { + try { + const auth = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) + if ("response" in auth) return auth.response + + const body = await parseJsonBody(request, updateSchema) + if ("response" in body) return body.response + + await dbConnect() + const userId = auth.user._id.toString() + if (body.data.action === "mark-all-read") { + await Notification.updateMany({ userId, read: false }, { $set: { read: true } }) + } else { + const result = await Notification.updateOne( + { _id: body.data.activityId, userId }, + { $set: { read: body.data.read } }, + ) + if (!result.matchedCount) return NextResponse.json({ error: "Activity not found" }, { status: 404 }) + } + + const unreadCount = await Notification.countDocuments({ userId, read: false }) + const response = NextResponse.json({ success: true, unreadCount }) + return finalizeAuthenticatedResponse(response, auth) + } catch (error) { + console.error("ACTIVITY_PATCH_ERROR", error) + return NextResponse.json({ error: "Failed to update activity" }, { status: 500 }) + } +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index f7d1857..fb02805 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -8,6 +8,8 @@ import Notification from "@/models/Notification" import User from "@/models/User" import { logAuditEvent } from "@/lib/security/audit-log" import { buildRateLimitKey, consumeRateLimit, getClientIpAddress, rateLimitExceededResponse } from "@/lib/security/rate-limit" +import { ACTIVITY_CATEGORIES, inferActivityCategory } from "@/lib/activity" +import { createActivity } from "@/lib/services/activity.service" const querySchema = z.object({ userId: z.string().trim().regex(/^[a-f\d]{24}$/i, "Invalid userId.").optional(), @@ -19,6 +21,7 @@ const bodySchema = z.object({ title: z.string().trim().min(1).max(160), message: z.string().trim().min(1).max(2000), type: z.string().trim().min(1).max(80).default("info"), + category: z.enum(ACTIVITY_CATEGORIES).optional(), priority: z.enum(["low", "medium", "high"]).default("medium"), actionUrl: z .string() @@ -54,12 +57,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: "User not found" }, { status: 404 }) } - const notification = await Notification.create({ + const notification = await createActivity({ userId: body.data.userId, - createdBy: authContext.user._id, + createdBy: authContext.user._id.toString(), title: body.data.title, message: body.data.message, type: body.data.type, + category: body.data.category || inferActivityCategory(body.data.type), priority: body.data.priority, link: body.data.actionUrl, }) diff --git a/app/dashboard/admin/activity/page.tsx b/app/dashboard/admin/activity/page.tsx new file mode 100644 index 0000000..21f5af9 --- /dev/null +++ b/app/dashboard/admin/activity/page.tsx @@ -0,0 +1,13 @@ +import { ActivityFeed } from "@/components/dashboard/activity-feed" + +export default function AdminActivityPage() { + return ( +
+
+

Activity

+

Review platform events and operational updates.

+
+ +
+ ) +} diff --git a/app/dashboard/driver/activity/page.tsx b/app/dashboard/driver/activity/page.tsx new file mode 100644 index 0000000..170c611 --- /dev/null +++ b/app/dashboard/driver/activity/page.tsx @@ -0,0 +1,5 @@ +import { RoleActivityPage } from "@/components/dashboard/role-activity-page" + +export default function DriverActivityPage() { + return +} diff --git a/app/dashboard/driver/notifications/page.tsx b/app/dashboard/driver/notifications/page.tsx index 2890ad4..f1699be 100644 --- a/app/dashboard/driver/notifications/page.tsx +++ b/app/dashboard/driver/notifications/page.tsx @@ -1,214 +1,5 @@ -"use client" +import { redirect } from "next/navigation" -import { useMemo, useState } from "react" -import { AlertTriangle, Bell, CheckCircle2, Info, Trash2 } from "lucide-react" -import { Sidebar } from "@/components/dashboard/sidebar" -import { Header } from "@/components/dashboard/header" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" - -type NotificationType = "payment" | "maintenance" | "document" | "info" - -interface DriverNotification { - id: string - title: string - message: string - type: NotificationType - priority: "high" | "medium" | "low" - read: boolean - timestamp: string -} - -const initialNotifications: DriverNotification[] = [ - { - id: "n-1", - title: "Payment due reminder", - message: "Your monthly installment is due in 3 days.", - type: "payment", - priority: "high", - read: false, - timestamp: "2026-02-12T09:10:00.000Z", - }, - { - id: "n-2", - title: "Maintenance check needed", - message: "Brake inspection is overdue. Please schedule service.", - type: "maintenance", - priority: "high", - read: false, - timestamp: "2026-02-10T07:40:00.000Z", - }, - { - id: "n-3", - title: "Document approved", - message: "Your latest compliance upload has been verified.", - type: "document", - priority: "low", - read: true, - timestamp: "2026-02-08T16:20:00.000Z", - }, -] - -const announcements = [ - { - id: "a-1", - title: "Scheduled platform maintenance", - message: "Maintenance window on February 20, 2026 from 02:00 to 04:00 UTC.", - important: true, - date: "2026-02-12", - }, - { - id: "a-2", - title: "New repayment reminder options", - message: "You can now configure weekly reminder cadence in settings.", - important: false, - date: "2026-02-09", - }, -] - -function iconForType(type: NotificationType) { - if (type === "payment") return - if (type === "maintenance") return - if (type === "document") return - return -} - -function badgeForPriority(priority: DriverNotification["priority"]) { - if (priority === "high") return High - if (priority === "medium") return Medium - return Low -} - -export default function NotificationsPage() { - const [items, setItems] = useState(initialNotifications) - - const unreadCount = useMemo(() => items.filter((item) => !item.read).length, [items]) - const highPriorityCount = useMemo(() => items.filter((item) => item.priority === "high").length, [items]) - - const markAllRead = () => { - setItems((prev) => prev.map((item) => ({ ...item, read: true }))) - } - - const removeNotification = (id: string) => { - setItems((prev) => prev.filter((item) => item.id !== id)) - } - - return ( -
- - -
-
- -
-
-
-
-

Notifications

-

- Review operational alerts and platform announcements. -

-
- - - {unreadCount} unread - -
-
- -
- - - Unread alerts - {unreadCount} - - Need your attention - - - - High priority - {highPriorityCount} - - Action recommended today - - - - Total notifications - {items.length} - - Recent account and platform updates - -
- - - - Alerts - Announcements - - - -
- -
- - {items.length === 0 ? ( - - - You have no notifications. - - - ) : ( -
- {items.map((item) => ( - - -
-
- {iconForType(item.type)} -
-
-

{item.title}

- {badgeForPriority(item.priority)} - {!item.read ? New : null} -
-

{item.message}

-

- {new Date(item.timestamp).toLocaleString()} -

-
-
- -
-
-
- ))} -
- )} -
- - - {announcements.map((item) => ( - - -
- {item.title} - {item.important ? Important : null} -
- {new Date(item.date).toLocaleDateString()} -
- {item.message} -
- ))} -
-
-
-
-
- ) +export default function LegacyNotificationsPage() { + redirect("/dashboard/driver/activity") } diff --git a/app/dashboard/investor/activity/page.tsx b/app/dashboard/investor/activity/page.tsx new file mode 100644 index 0000000..2983f4e --- /dev/null +++ b/app/dashboard/investor/activity/page.tsx @@ -0,0 +1,5 @@ +import { RoleActivityPage } from "@/components/dashboard/role-activity-page" + +export default function InvestorActivityPage() { + return +} diff --git a/components/dashboard/activity-feed.tsx b/components/dashboard/activity-feed.tsx new file mode 100644 index 0000000..8c496cb --- /dev/null +++ b/components/dashboard/activity-feed.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react" +import Link from "next/link" +import { + AlertCircle, + Car, + Check, + CircleDollarSign, + ExternalLink, + FileCheck2, + Landmark, + Orbit, + RefreshCw, + Settings, + Wallet, +} from "lucide-react" + +import { publishActivityUnreadCount } from "@/components/dashboard/activity-unread-bell" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { ACTIVITY_CATEGORIES, ACTIVITY_CATEGORY_LABELS, type ActivityCategory, type ActivityItem } from "@/lib/activity" +import { cn } from "@/lib/utils" + +const categoryIcons: Record> = { + wallet: Wallet, + investment: Landmark, + repayment: CircleDollarSign, + kyc: FileCheck2, + vehicle: Car, + payout: CircleDollarSign, + stellar: Orbit, + system: Settings, +} + +type Filter = "all" | "unread" | ActivityCategory + +export function ActivityFeed() { + const [activities, setActivities] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [filter, setFilter] = useState("all") + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [updating, setUpdating] = useState(false) + + const loadActivities = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await fetch("/api/activity", { cache: "no-store" }) + if (!response.ok) throw new Error("The activity feed could not be loaded.") + const data = await response.json() + setActivities(data.activities || []) + setUnreadCount(data.unreadCount || 0) + publishActivityUnreadCount(data.unreadCount || 0) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "The activity feed could not be loaded.") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadActivities() + }, [loadActivities]) + + const visibleActivities = useMemo(() => { + if (filter === "all") return activities + if (filter === "unread") return activities.filter((activity) => !activity.read) + return activities.filter((activity) => activity.category === filter) + }, [activities, filter]) + + const setRead = async (activityId: string, read: boolean) => { + const current = activities.find((activity) => activity.id === activityId) + if (!current || current.read === read) return + + setActivities((items) => items.map((item) => (item.id === activityId ? { ...item, read } : item))) + const nextCount = Math.max(0, unreadCount + (read ? -1 : 1)) + setUnreadCount(nextCount) + publishActivityUnreadCount(nextCount) + + const response = await fetch("/api/activity", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-read", activityId, read }), + }) + if (!response.ok) void loadActivities() + } + + const markAllRead = async () => { + setUpdating(true) + try { + const response = await fetch("/api/activity", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "mark-all-read" }), + }) + if (!response.ok) throw new Error() + setActivities((items) => items.map((item) => ({ ...item, read: true }))) + setUnreadCount(0) + publishActivityUnreadCount(0) + } catch { + setError("Read state could not be updated. Please try again.") + } finally { + setUpdating(false) + } + } + + return ( + + +
+
+ Recent activity + Account and platform updates relevant to your role. +
+ +
+
+ {(["all", "unread", ...ACTIVITY_CATEGORIES] as Filter[]).map((value) => ( + + ))} +
+
+ + + {loading ? ( +
+ {[0, 1, 2].map((item) => ( +
+ +
+
+ ))} +
+ ) : error ? ( +
+ + + Unable to load activity + + {error} + + + +
+ ) : visibleActivities.length === 0 ? ( +
+ +

You’re all caught up

+

+ {filter === "all" ? "New activity will appear here as it happens." : "There is no activity matching this filter."} +

+
+ ) : ( +
    + {visibleActivities.map((activity) => { + const Icon = categoryIcons[activity.category] + return ( +
  1. +
    + + {!activity.read ? : null} +
    +
    +
    +

    {activity.title}

    + {ACTIVITY_CATEGORY_LABELS[activity.category]} +
    +

    {activity.message}

    +
    + + {activity.link ? ( + void setRead(activity.id, true)} className="inline-flex items-center font-medium text-primary hover:underline"> + View details + + ) : null} + +
    +
    +
  2. + ) + })} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/activity-unread-bell.tsx b/components/dashboard/activity-unread-bell.tsx new file mode 100644 index 0000000..d02a12a --- /dev/null +++ b/components/dashboard/activity-unread-bell.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Bell } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface ActivityUnreadBellProps { + role?: "driver" | "investor" | "admin" + fallbackCount?: number + compact?: boolean + className?: string +} + +export const ACTIVITY_COUNT_CHANGED_EVENT = "chainmove:activity-count-changed" + +export function publishActivityUnreadCount(unreadCount: number) { + window.dispatchEvent(new CustomEvent(ACTIVITY_COUNT_CHANGED_EVENT, { detail: unreadCount })) +} + +export function ActivityUnreadBell({ role, fallbackCount = 0, compact = false, className }: ActivityUnreadBellProps) { + const [unreadCount, setUnreadCount] = useState(fallbackCount) + + useEffect(() => { + if (role) { + fetch("/api/activity?limit=1", { cache: "no-store" }) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (data) setUnreadCount(data.unreadCount || 0) + }) + .catch(() => { + // Keep the last known count; the activity page exposes a retry state. + }) + } + const updateCount = (event: Event) => setUnreadCount((event as CustomEvent).detail) + window.addEventListener(ACTIVITY_COUNT_CHANGED_EVENT, updateCount) + return () => window.removeEventListener(ACTIVITY_COUNT_CHANGED_EVENT, updateCount) + }, [role]) + + const href = role ? `/dashboard/${role}/activity` : "#" + return ( + + ) +} diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx index 1d16a53..3c8defa 100644 --- a/components/dashboard/header.tsx +++ b/components/dashboard/header.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { ActivityUnreadBell } from "@/components/dashboard/activity-unread-bell" import { emitDashboardSidebarToggle } from "@/components/dashboard/sidebar-events" import { ThemeToggle } from "@/components/theme-toggle" import { @@ -13,7 +14,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Bell, ChevronLeft, Menu, MoreVertical, User } from "lucide-react" +import { ChevronLeft, Menu, MoreVertical, User } from "lucide-react" import { cn } from "@/lib/utils" import { getUserDisplayName, useAuth } from "@/hooks/use-auth" import { resolveDashboardUserStatus } from "@/lib/users/user-profile" @@ -130,19 +131,14 @@ export function Header({ - +
diff --git a/components/dashboard/investor-overview/dashboard-header.tsx b/components/dashboard/investor-overview/dashboard-header.tsx index 2a06caf..0a0de8b 100644 --- a/components/dashboard/investor-overview/dashboard-header.tsx +++ b/components/dashboard/investor-overview/dashboard-header.tsx @@ -1,10 +1,12 @@ "use client" -import { Bell, ChevronDown, Menu, MoreVertical, User } from "lucide-react" +import { ChevronDown, Menu, MoreVertical, User } from "lucide-react" import { emitDashboardSidebarToggle } from "@/components/dashboard/sidebar-events" import { ThemeToggle } from "@/components/theme-toggle" import { Button } from "@/components/ui/button" +import { ActivityUnreadBell } from "@/components/dashboard/activity-unread-bell" +import { useAuth } from "@/hooks/use-auth" import { DropdownMenu, DropdownMenuContent, @@ -28,6 +30,8 @@ export function DashboardHeader({ onWalletChipClick, notificationCount = 0, }: DashboardHeaderProps) { + const { user } = useAuth() + return (
@@ -52,19 +56,11 @@ export function DashboardHeader({
- +