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
232 changes: 232 additions & 0 deletions app/api/notifications/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { withAuth } from "@/lib/auth/middleware";
import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit";
import { getUserIdByWallet } from "@/lib/reputation";
import {
markAsRead,
markAsUnread,
deleteNotification,
listNotifications,
} from "@/lib/notifications";

// ─── Validation schemas ────────────────────────────────────────────────────

const ParamsSchema = z.object({
id: z.string().uuid("Invalid notification ID"),
});

const PatchSchema = z.object({
action: z.enum(["read", "unread"]),
});

// ─── PATCH /api/notifications/[id] ─────────────────────────────────────────

/**
* PATCH /api/notifications/[id]
* Body: { action: "read" | "unread" }
*
* Marks a single notification as read or unread.
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<Record<string, string>> },
) {
return withAuth(async (request: NextRequest, auth) => {
const limited = await enforceRateLimit(request, {
key: buildRateLimitKey(
request,
"notifications:update",
auth.walletAddress,
),
limit: 30,
windowMs: 60_000,
});
if (limited) return limited;

const userId = await getUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{
error: "Platform user not found for this wallet",
code: "USER_NOT_FOUND",
},
{ status: 404 },
);
}

const resolvedParams = await params;
const paramsParsed = ParamsSchema.safeParse(resolvedParams);
if (!paramsParsed.success) {
return NextResponse.json(
{
error: "Validation failed",
details: paramsParsed.error.flatten().fieldErrors,
},
{ status: 422 },
);
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Request body must be valid JSON" },
{ status: 400 },
);
}

const bodyParsed = PatchSchema.safeParse(body);
if (!bodyParsed.success) {
return NextResponse.json(
{
error: "Validation failed",
details: bodyParsed.error.flatten().fieldErrors,
},
{ status: 422 },
);
}

try {
// Verify the notification belongs to this user
const notifications = await listNotifications({
userId,
limit: 1,
offset: 0,
});

const isOwned = notifications.some((n) => n.id === paramsParsed.data.id);
if (!isOwned) {
// Double-check by fetching all notifications more broadly
// In production, you might want to do a direct DB check
const allNotifs = await listNotifications({
userId,
limit: 1000,
offset: 0,
});

if (!allNotifs.some((n) => n.id === paramsParsed.data.id)) {
return NextResponse.json(
{ error: "Notification not found or access denied" },
{ status: 404 },
);
}
}

if (bodyParsed.data.action === "read") {
const success = await markAsRead(paramsParsed.data.id);
if (!success) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 },
);
}
} else {
const success = await markAsUnread(paramsParsed.data.id);
if (!success) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 },
);
}
}

return NextResponse.json(
{ message: `Notification marked as ${bodyParsed.data.action}` },
{ status: 200 },
);
} catch (err) {
console.error("[PATCH /api/notifications/[id]]", err);
return NextResponse.json(
{ error: "Failed to update notification" },
{ status: 500 },
);
}
})(request, { params });
}

// ─── DELETE /api/notifications/[id] ────────────────────────────────────────

/**
* DELETE /api/notifications/[id]
*
* Deletes a single notification.
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<Record<string, string>> },
) {
return withAuth(async (request: NextRequest, auth) => {
const limited = await enforceRateLimit(request, {
key: buildRateLimitKey(
request,
"notifications:delete",
auth.walletAddress,
),
limit: 30,
windowMs: 60_000,
});
if (limited) return limited;

const userId = await getUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{
error: "Platform user not found for this wallet",
code: "USER_NOT_FOUND",
},
{ status: 404 },
);
}

const resolvedParams = await params;
const paramsParsed = ParamsSchema.safeParse(resolvedParams);
if (!paramsParsed.success) {
return NextResponse.json(
{
error: "Validation failed",
details: paramsParsed.error.flatten().fieldErrors,
},
{ status: 422 },
);
}

try {
// Verify the notification belongs to this user
const allNotifs = await listNotifications({
userId,
limit: 1000,
offset: 0,
});

if (!allNotifs.some((n) => n.id === paramsParsed.data.id)) {
return NextResponse.json(
{ error: "Notification not found or access denied" },
{ status: 404 },
);
}

const success = await deleteNotification(paramsParsed.data.id);
if (!success) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 },
);
}

return NextResponse.json(
{ message: "Notification deleted" },
{ status: 200 },
);
} catch (err) {
console.error("[DELETE /api/notifications/[id]]", err);
return NextResponse.json(
{ error: "Failed to delete notification" },
{ status: 500 },
);
}
})(request, { params });
}
145 changes: 145 additions & 0 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { withAuth } from "@/lib/auth/middleware";
import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit";
import { getUserIdByWallet } from "@/lib/reputation";
import {
listNotifications as listNotificationsDb,
countNotifications as countNotificationsDb,
markAllAsRead as markAllAsReadDb,
} from "@/lib/notifications";

// ─── Validation schemas ────────────────────────────────────────────────────

const ListNotificationsSchema = z.object({
isRead: z.coerce.boolean().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
});

// ─── GET /api/notifications ────────────────────────────────────────────────

/**
* GET /api/notifications?limit=20&offset=0&isRead=false
*
* Returns a paginated list of notifications for the authenticated user.
* Query parameters:
* - limit: max 100, default 20
* - offset: pagination offset, default 0
* - isRead: filter by read status (optional)
*/
export const GET = withAuth(async (request: NextRequest, auth) => {
const limited = await enforceRateLimit(request, {
key: buildRateLimitKey(request, "notifications:list", auth.walletAddress),
limit: 60,
windowMs: 60_000,
});
if (limited) return limited;

const userId = await getUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{
error: "Platform user not found for this wallet",
code: "USER_NOT_FOUND",
},
{ status: 404 },
);
}

const { searchParams } = request.nextUrl;
const parsed = ListNotificationsSchema.safeParse({
isRead: searchParams.get("isRead") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
});

if (!parsed.success) {
return NextResponse.json(
{
error: "Validation failed",
details: parsed.error.flatten().fieldErrors,
},
{ status: 422 },
);
}

try {
const notifications = await listNotificationsDb({
userId,
isRead: parsed.data.isRead,
limit: parsed.data.limit,
offset: parsed.data.offset,
});

const total = await countNotificationsDb(userId, parsed.data.isRead);

return NextResponse.json(
{
notifications,
pagination: {
total,
limit: parsed.data.limit ?? 20,
offset: parsed.data.offset ?? 0,
hasMore: (parsed.data.offset ?? 0) + notifications.length < total,
},
},
{
status: 200,
headers: { "Cache-Control": "private, no-store" },
},
);
} catch (err) {
console.error("[GET /api/notifications]", err);
return NextResponse.json(
{ error: "Failed to fetch notifications" },
{ status: 500 },
);
}
});

// ─── PATCH /api/notifications (mark all as read) ────────────────────────────

/**
* PATCH /api/notifications
*
* Marks all unread notifications for the authenticated user as read.
*/
export const PATCH = withAuth(async (request: NextRequest, auth) => {
const limited = await enforceRateLimit(request, {
key: buildRateLimitKey(request, "notifications:update", auth.walletAddress),
limit: 30,
windowMs: 60_000,
});
if (limited) return limited;

const userId = await getUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{
error: "Platform user not found for this wallet",
code: "USER_NOT_FOUND",
},
{ status: 404 },
);
}

try {
const updatedCount = await markAllAsReadDb(userId);
return NextResponse.json(
{
message: "All notifications marked as read",
updatedCount,
},
{ status: 200 },
);
} catch (err) {
console.error("[PATCH /api/notifications]", err);
return NextResponse.json(
{ error: "Failed to update notifications" },
{ status: 500 },
);
}
});
Loading
Loading