Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/api/activity/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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 })
}
}
8 changes: 6 additions & 2 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
Expand Down Expand Up @@ -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,
})
Expand Down
13 changes: 13 additions & 0 deletions app/dashboard/admin/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ActivityFeed } from "@/components/dashboard/activity-feed"

export default function AdminActivityPage() {
return (
<div className="mx-auto w-full max-w-5xl space-y-5">
<section>
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
<p className="mt-1 text-sm text-muted-foreground">Review platform events and operational updates.</p>
</section>
<ActivityFeed />
</div>
)
}
5 changes: 5 additions & 0 deletions app/dashboard/driver/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { RoleActivityPage } from "@/components/dashboard/role-activity-page"

export default function DriverActivityPage() {
return <RoleActivityPage role="driver" />
}
215 changes: 3 additions & 212 deletions app/dashboard/driver/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <AlertTriangle className="h-4 w-4 text-red-600" />
if (type === "maintenance") return <AlertTriangle className="h-4 w-4 text-amber-600" />
if (type === "document") return <CheckCircle2 className="h-4 w-4 text-emerald-600" />
return <Info className="h-4 w-4 text-blue-600" />
}

function badgeForPriority(priority: DriverNotification["priority"]) {
if (priority === "high") return <Badge className="bg-red-600 text-white">High</Badge>
if (priority === "medium") return <Badge className="bg-amber-600 text-white">Medium</Badge>
return <Badge className="bg-emerald-600 text-white">Low</Badge>
}

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 (
<div className="min-h-screen bg-background">
<Sidebar role="driver" />

<div className="md:ml-64 lg:ml-72">
<Header userStatus="Verified Driver" notificationCount={unreadCount} />

<main className="space-y-6 p-4 sm:p-6 lg:p-8">
<section className="rounded-2xl border bg-card p-5 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Notifications</h1>
<p className="mt-1 text-sm text-muted-foreground">
Review operational alerts and platform announcements.
</p>
</div>
<Badge className="w-fit bg-[#E57700] text-white">
<Bell className="mr-1 h-4 w-4" />
{unreadCount} unread
</Badge>
</div>
</section>

<section className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Unread alerts</CardDescription>
<CardTitle>{unreadCount}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">Need your attention</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>High priority</CardDescription>
<CardTitle>{highPriorityCount}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">Action recommended today</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Total notifications</CardDescription>
<CardTitle>{items.length}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">Recent account and platform updates</CardContent>
</Card>
</section>

<Tabs defaultValue="alerts" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="alerts">Alerts</TabsTrigger>
<TabsTrigger value="announcements">Announcements</TabsTrigger>
</TabsList>

<TabsContent value="alerts" className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={markAllRead}>
Mark all read
</Button>
</div>

{items.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
You have no notifications.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{items.map((item) => (
<Card key={item.id} className={!item.read ? "border-l-4 border-l-[#E57700]" : ""}>
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3">
{iconForType(item.type)}
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<p className="font-medium">{item.title}</p>
{badgeForPriority(item.priority)}
{!item.read ? <Badge variant="secondary">New</Badge> : null}
</div>
<p className="text-sm text-muted-foreground">{item.message}</p>
<p className="mt-1 text-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleString()}
</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={() => removeNotification(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>

<TabsContent value="announcements" className="space-y-3">
{announcements.map((item) => (
<Card key={item.id}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{item.title}</CardTitle>
{item.important ? <Badge className="bg-red-600 text-white">Important</Badge> : null}
</div>
<CardDescription>{new Date(item.date).toLocaleDateString()}</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{item.message}</CardContent>
</Card>
))}
</TabsContent>
</Tabs>
</main>
</div>
</div>
)
export default function LegacyNotificationsPage() {
redirect("/dashboard/driver/activity")
}
5 changes: 5 additions & 0 deletions app/dashboard/investor/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { RoleActivityPage } from "@/components/dashboard/role-activity-page"

export default function InvestorActivityPage() {
return <RoleActivityPage role="investor" />
}
Loading
Loading