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
576 changes: 576 additions & 0 deletions __tests__/api/notifications.test.ts

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions app/api/notifications/[id]/read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'

import {
NotificationError,
markNotificationRead,
} from '@/lib/notifications'
import { sql } from '@/lib/db'
import { withAuthCtx, AuthContext, resolveUserIdByWallet as resolveUserId } from '@/lib/auth/middleware'

export const dynamic = 'force-dynamic'

type RouteContext = { params: Promise<{ id: string }> }

/**
* PATCH /api/notifications/[id]/read
*
* Marks a single notification as read for the authenticated user. The
* notification must belong to the caller — otherwise we return 404 to avoid
* leaking whether the id exists for someone else.
*
* Status codes:
* 200 ok {notification}
* 400 invalid id or NotificationError → 400
* 401 auth required
* 404 not found / not the caller's notification
* 503 db failure
*/
export const PATCH = withAuthCtx<RouteContext>(
async (request: NextRequest, auth: AuthContext, context: RouteContext) => {
void request
const { id: rawId } = await context.params
const notificationId = Number.parseInt(rawId, 10)

if (!Number.isFinite(notificationId) || notificationId <= 0) {
return NextResponse.json(
{ error: 'Invalid notification id', code: 'INVALID_ID' },
{ status: 400 },
)
}

try {
const userId = await resolveUserId(auth.walletAddress)
if (userId === null) {
return NextResponse.json(
{ error: 'User not found', code: 'USER_NOT_FOUND' },
{ status: 404 },
)
}

const result = await markNotificationRead(notificationId, userId)
if (result.notification === null) {
return NextResponse.json(
{ error: 'Notification not found', code: 'NOT_FOUND' },
{ status: 404 },
)
}

return NextResponse.json(
{ notification: result.notification },
{
headers: {
'Cache-Control': 'no-store',
},
},
)
} catch (error) {
if (error instanceof NotificationError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 400 },
)
}
console.error(
`Failed to mark notification ${notificationId} read:`,
error,
)
return NextResponse.json(
{ error: 'Unable to mark notification read', code: 'NOTIFICATION_UPDATE_FAILED' },
{ status: 503 },
)
}
},
)


53 changes: 53 additions & 0 deletions app/api/notifications/read-all/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'

import {
NotificationError,
markAllNotificationsRead,
} from '@/lib/notifications'
import {
withAuth,
AuthContext,
resolveUserIdByWallet as resolveUserId,
} from '@/lib/auth/middleware'

export const dynamic = 'force-dynamic'

/**
* POST /api/notifications/read-all
*
* Marks every unread notification for the authenticated user as read.
* Returns the number of rows updated (returns 0 if there were none).
*
* Status codes:
* 200 ok {updatedCount}
* 400 NotificationError
* 401 auth required
* 503 db failure
*/
export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => {
void request
try {
const userId = await resolveUserId(auth.walletAddress)
if (userId === null) {
return NextResponse.json(
{ error: 'User not found', code: 'USER_NOT_FOUND' },
{ status: 404 },
)
}

const result = await markAllNotificationsRead(userId)
return NextResponse.json({ updatedCount: result.updatedCount })
} catch (error) {
if (error instanceof NotificationError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: 400 },
)
}
console.error('Failed to mark all notifications read:', error)
return NextResponse.json(
{ error: 'Unable to mark all notifications read', code: 'NOTIFICATION_UPDATE_FAILED' },
{ status: 503 },
)
}
})
161 changes: 65 additions & 96 deletions app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,57 @@
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 { withAuth, AuthContext, resolveUserIdByWallet } from "@/lib/auth/middleware";
import {
listNotifications as listNotificationsDb,
countNotifications as countNotificationsDb,
markAllAsRead as markAllAsReadDb,
parseNotificationQuery,
listNotificationsForUser,
markAllNotificationsRead,
getUnreadCount,
NotificationError,
} 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
* GET /api/notifications?page=1&limit=20&type=milestone_approved&unreadOnly=true
*
* 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)
* - page: ≥1, default 1
* - limit: 1..100, default 20
* - type: one of NOTIFICATION_EVENT_TYPES, optional
* - unreadOnly: true/1, optional
*
* Response:
* { data: Notification[], meta: { totalCount, page, limit, totalPages, unreadCount } }
*/
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 },
);
}

export const GET = withAuth(async (request: NextRequest, auth: AuthContext) => {
void request;
try {
const notifications = await listNotificationsDb({
userId,
isRead: parsed.data.isRead,
limit: parsed.data.limit,
offset: parsed.data.offset,
});
const userId = await resolveUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{ error: "Platform user not found for this wallet", code: "USER_NOT_FOUND" },
{ status: 404 },
);
}

const query = parseNotificationQuery(request.nextUrl.searchParams);

const total = await countNotificationsDb(userId, parsed.data.isRead);
const [result, unreadCount] = await Promise.all([
listNotificationsForUser(userId, query),
getUnreadCount(userId),
]);

return NextResponse.json(
{
notifications,
pagination: {
total,
limit: parsed.data.limit ?? 20,
offset: parsed.data.offset ?? 0,
hasMore: (parsed.data.offset ?? 0) + notifications.length < total,
data: result.notifications,
meta: {
totalCount: result.totalItems,
page: query.page,
limit: query.limit,
totalPages: Math.max(1, Math.ceil(result.totalItems / query.limit)),
unreadCount,
},
},
{
Expand All @@ -92,10 +60,16 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
},
);
} catch (err) {
if (err instanceof NotificationError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
);
}
console.error("[GET /api/notifications]", err);
return NextResponse.json(
{ error: "Failed to fetch notifications" },
{ status: 500 },
{ error: "Failed to fetch notifications", code: "NOTIFICATIONS_LIST_FAILED" },
{ status: 503 },
);
}
});
Expand All @@ -106,40 +80,35 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
* PATCH /api/notifications
*
* Marks all unread notifications for the authenticated user as read.
* Returns { updatedCount }.
*/
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 },
);
}

export const PATCH = withAuth(async (request: NextRequest, auth: AuthContext) => {
void request;
try {
const updatedCount = await markAllAsReadDb(userId);
const userId = await resolveUserIdByWallet(auth.walletAddress);
if (userId === null) {
return NextResponse.json(
{ error: "Platform user not found for this wallet", code: "USER_NOT_FOUND" },
{ status: 404 },
);
}

const result = await markAllNotificationsRead(userId);
return NextResponse.json(
{
message: "All notifications marked as read",
updatedCount,
},
{ message: "All notifications marked as read", updatedCount: result.updatedCount },
{ status: 200 },
);
} catch (err) {
if (err instanceof NotificationError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 400 },
);
}
console.error("[PATCH /api/notifications]", err);
return NextResponse.json(
{ error: "Failed to update notifications" },
{ status: 500 },
{ error: "Failed to update notifications", code: "NOTIFICATION_UPDATE_FAILED" },
{ status: 503 },
);
}
});
Loading
Loading