Skip to content

Commit 4c24d3f

Browse files
Merge pull request #173 from iyanumajekodunmi756/feat/notification-service-122
feat(notifications): notification service backend + hardening (closes #122)
2 parents afcf616 + e9d5fb7 commit 4c24d3f

9 files changed

Lines changed: 1657 additions & 177 deletions

File tree

__tests__/api/notifications.test.ts

Lines changed: 576 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import {
4+
NotificationError,
5+
markNotificationRead,
6+
} from '@/lib/notifications'
7+
import { sql } from '@/lib/db'
8+
import { withAuthCtx, AuthContext, resolveUserIdByWallet as resolveUserId } from '@/lib/auth/middleware'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
type RouteContext = { params: Promise<{ id: string }> }
13+
14+
/**
15+
* PATCH /api/notifications/[id]/read
16+
*
17+
* Marks a single notification as read for the authenticated user. The
18+
* notification must belong to the caller — otherwise we return 404 to avoid
19+
* leaking whether the id exists for someone else.
20+
*
21+
* Status codes:
22+
* 200 ok {notification}
23+
* 400 invalid id or NotificationError → 400
24+
* 401 auth required
25+
* 404 not found / not the caller's notification
26+
* 503 db failure
27+
*/
28+
export const PATCH = withAuthCtx<RouteContext>(
29+
async (request: NextRequest, auth: AuthContext, context: RouteContext) => {
30+
void request
31+
const { id: rawId } = await context.params
32+
const notificationId = Number.parseInt(rawId, 10)
33+
34+
if (!Number.isFinite(notificationId) || notificationId <= 0) {
35+
return NextResponse.json(
36+
{ error: 'Invalid notification id', code: 'INVALID_ID' },
37+
{ status: 400 },
38+
)
39+
}
40+
41+
try {
42+
const userId = await resolveUserId(auth.walletAddress)
43+
if (userId === null) {
44+
return NextResponse.json(
45+
{ error: 'User not found', code: 'USER_NOT_FOUND' },
46+
{ status: 404 },
47+
)
48+
}
49+
50+
const result = await markNotificationRead(notificationId, userId)
51+
if (result.notification === null) {
52+
return NextResponse.json(
53+
{ error: 'Notification not found', code: 'NOT_FOUND' },
54+
{ status: 404 },
55+
)
56+
}
57+
58+
return NextResponse.json(
59+
{ notification: result.notification },
60+
{
61+
headers: {
62+
'Cache-Control': 'no-store',
63+
},
64+
},
65+
)
66+
} catch (error) {
67+
if (error instanceof NotificationError) {
68+
return NextResponse.json(
69+
{ error: error.message, code: error.code },
70+
{ status: 400 },
71+
)
72+
}
73+
console.error(
74+
`Failed to mark notification ${notificationId} read:`,
75+
error,
76+
)
77+
return NextResponse.json(
78+
{ error: 'Unable to mark notification read', code: 'NOTIFICATION_UPDATE_FAILED' },
79+
{ status: 503 },
80+
)
81+
}
82+
},
83+
)
84+
85+
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import {
4+
NotificationError,
5+
markAllNotificationsRead,
6+
} from '@/lib/notifications'
7+
import {
8+
withAuth,
9+
AuthContext,
10+
resolveUserIdByWallet as resolveUserId,
11+
} from '@/lib/auth/middleware'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
/**
16+
* POST /api/notifications/read-all
17+
*
18+
* Marks every unread notification for the authenticated user as read.
19+
* Returns the number of rows updated (returns 0 if there were none).
20+
*
21+
* Status codes:
22+
* 200 ok {updatedCount}
23+
* 400 NotificationError
24+
* 401 auth required
25+
* 503 db failure
26+
*/
27+
export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => {
28+
void request
29+
try {
30+
const userId = await resolveUserId(auth.walletAddress)
31+
if (userId === null) {
32+
return NextResponse.json(
33+
{ error: 'User not found', code: 'USER_NOT_FOUND' },
34+
{ status: 404 },
35+
)
36+
}
37+
38+
const result = await markAllNotificationsRead(userId)
39+
return NextResponse.json({ updatedCount: result.updatedCount })
40+
} catch (error) {
41+
if (error instanceof NotificationError) {
42+
return NextResponse.json(
43+
{ error: error.message, code: error.code },
44+
{ status: 400 },
45+
)
46+
}
47+
console.error('Failed to mark all notifications read:', error)
48+
return NextResponse.json(
49+
{ error: 'Unable to mark all notifications read', code: 'NOTIFICATION_UPDATE_FAILED' },
50+
{ status: 503 },
51+
)
52+
}
53+
})

app/api/notifications/route.ts

Lines changed: 65 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,57 @@
11
export const dynamic = "force-dynamic";
22

33
import { NextRequest, NextResponse } from "next/server";
4-
import { z } from "zod";
5-
import { withAuth } from "@/lib/auth/middleware";
6-
import { enforceRateLimit, buildRateLimitKey } from "@/lib/security/rateLimit";
7-
import { getUserIdByWallet } from "@/lib/reputation";
4+
import { withAuth, AuthContext, resolveUserIdByWallet } from "@/lib/auth/middleware";
85
import {
9-
listNotifications as listNotificationsDb,
10-
countNotifications as countNotificationsDb,
11-
markAllAsRead as markAllAsReadDb,
6+
parseNotificationQuery,
7+
listNotificationsForUser,
8+
markAllNotificationsRead,
9+
getUnreadCount,
10+
NotificationError,
1211
} from "@/lib/notifications";
1312

14-
// ─── Validation schemas ────────────────────────────────────────────────────
15-
16-
const ListNotificationsSchema = z.object({
17-
isRead: z.coerce.boolean().optional(),
18-
limit: z.coerce.number().int().min(1).max(100).optional(),
19-
offset: z.coerce.number().int().min(0).optional(),
20-
});
21-
2213
// ─── GET /api/notifications ────────────────────────────────────────────────
2314

2415
/**
25-
* GET /api/notifications?limit=20&offset=0&isRead=false
16+
* GET /api/notifications?page=1&limit=20&type=milestone_approved&unreadOnly=true
2617
*
2718
* Returns a paginated list of notifications for the authenticated user.
2819
* Query parameters:
29-
* - limit: max 100, default 20
30-
* - offset: pagination offset, default 0
31-
* - isRead: filter by read status (optional)
20+
* - page: ≥1, default 1
21+
* - limit: 1..100, default 20
22+
* - type: one of NOTIFICATION_EVENT_TYPES, optional
23+
* - unreadOnly: true/1, optional
24+
*
25+
* Response:
26+
* { data: Notification[], meta: { totalCount, page, limit, totalPages, unreadCount } }
3227
*/
33-
export const GET = withAuth(async (request: NextRequest, auth) => {
34-
const limited = await enforceRateLimit(request, {
35-
key: buildRateLimitKey(request, "notifications:list", auth.walletAddress),
36-
limit: 60,
37-
windowMs: 60_000,
38-
});
39-
if (limited) return limited;
40-
41-
const userId = await getUserIdByWallet(auth.walletAddress);
42-
if (userId === null) {
43-
return NextResponse.json(
44-
{
45-
error: "Platform user not found for this wallet",
46-
code: "USER_NOT_FOUND",
47-
},
48-
{ status: 404 },
49-
);
50-
}
51-
52-
const { searchParams } = request.nextUrl;
53-
const parsed = ListNotificationsSchema.safeParse({
54-
isRead: searchParams.get("isRead") ?? undefined,
55-
limit: searchParams.get("limit") ?? undefined,
56-
offset: searchParams.get("offset") ?? undefined,
57-
});
58-
59-
if (!parsed.success) {
60-
return NextResponse.json(
61-
{
62-
error: "Validation failed",
63-
details: parsed.error.flatten().fieldErrors,
64-
},
65-
{ status: 422 },
66-
);
67-
}
68-
28+
export const GET = withAuth(async (request: NextRequest, auth: AuthContext) => {
29+
void request;
6930
try {
70-
const notifications = await listNotificationsDb({
71-
userId,
72-
isRead: parsed.data.isRead,
73-
limit: parsed.data.limit,
74-
offset: parsed.data.offset,
75-
});
31+
const userId = await resolveUserIdByWallet(auth.walletAddress);
32+
if (userId === null) {
33+
return NextResponse.json(
34+
{ error: "Platform user not found for this wallet", code: "USER_NOT_FOUND" },
35+
{ status: 404 },
36+
);
37+
}
38+
39+
const query = parseNotificationQuery(request.nextUrl.searchParams);
7640

77-
const total = await countNotificationsDb(userId, parsed.data.isRead);
41+
const [result, unreadCount] = await Promise.all([
42+
listNotificationsForUser(userId, query),
43+
getUnreadCount(userId),
44+
]);
7845

7946
return NextResponse.json(
8047
{
81-
notifications,
82-
pagination: {
83-
total,
84-
limit: parsed.data.limit ?? 20,
85-
offset: parsed.data.offset ?? 0,
86-
hasMore: (parsed.data.offset ?? 0) + notifications.length < total,
48+
data: result.notifications,
49+
meta: {
50+
totalCount: result.totalItems,
51+
page: query.page,
52+
limit: query.limit,
53+
totalPages: Math.max(1, Math.ceil(result.totalItems / query.limit)),
54+
unreadCount,
8755
},
8856
},
8957
{
@@ -92,10 +60,16 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
9260
},
9361
);
9462
} catch (err) {
63+
if (err instanceof NotificationError) {
64+
return NextResponse.json(
65+
{ error: err.message, code: err.code },
66+
{ status: 400 },
67+
);
68+
}
9569
console.error("[GET /api/notifications]", err);
9670
return NextResponse.json(
97-
{ error: "Failed to fetch notifications" },
98-
{ status: 500 },
71+
{ error: "Failed to fetch notifications", code: "NOTIFICATIONS_LIST_FAILED" },
72+
{ status: 503 },
9973
);
10074
}
10175
});
@@ -106,40 +80,35 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
10680
* PATCH /api/notifications
10781
*
10882
* Marks all unread notifications for the authenticated user as read.
83+
* Returns { updatedCount }.
10984
*/
110-
export const PATCH = withAuth(async (request: NextRequest, auth) => {
111-
const limited = await enforceRateLimit(request, {
112-
key: buildRateLimitKey(request, "notifications:update", auth.walletAddress),
113-
limit: 30,
114-
windowMs: 60_000,
115-
});
116-
if (limited) return limited;
117-
118-
const userId = await getUserIdByWallet(auth.walletAddress);
119-
if (userId === null) {
120-
return NextResponse.json(
121-
{
122-
error: "Platform user not found for this wallet",
123-
code: "USER_NOT_FOUND",
124-
},
125-
{ status: 404 },
126-
);
127-
}
128-
85+
export const PATCH = withAuth(async (request: NextRequest, auth: AuthContext) => {
86+
void request;
12987
try {
130-
const updatedCount = await markAllAsReadDb(userId);
88+
const userId = await resolveUserIdByWallet(auth.walletAddress);
89+
if (userId === null) {
90+
return NextResponse.json(
91+
{ error: "Platform user not found for this wallet", code: "USER_NOT_FOUND" },
92+
{ status: 404 },
93+
);
94+
}
95+
96+
const result = await markAllNotificationsRead(userId);
13197
return NextResponse.json(
132-
{
133-
message: "All notifications marked as read",
134-
updatedCount,
135-
},
98+
{ message: "All notifications marked as read", updatedCount: result.updatedCount },
13699
{ status: 200 },
137100
);
138101
} catch (err) {
102+
if (err instanceof NotificationError) {
103+
return NextResponse.json(
104+
{ error: err.message, code: err.code },
105+
{ status: 400 },
106+
);
107+
}
139108
console.error("[PATCH /api/notifications]", err);
140109
return NextResponse.json(
141-
{ error: "Failed to update notifications" },
142-
{ status: 500 },
110+
{ error: "Failed to update notifications", code: "NOTIFICATION_UPDATE_FAILED" },
111+
{ status: 503 },
143112
);
144113
}
145114
});

0 commit comments

Comments
 (0)