From f28a6be4592e118ab166e1edccd3e3dabcad6cba Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Thu, 27 Aug 2026 02:45:28 +0800 Subject: [PATCH 1/4] feat: improve rate limiter without disrupting UI and wakatime syncing --- app/api/wakatime/sync/route.ts | 28 +++++++++---------- app/components/auth/VerifyWakatime.tsx | 22 +++++++++++---- .../dashboard/Settings/WakaTimeKey.tsx | 17 +++++------ app/lib/proxy/rate-limiter.ts | 27 ++++++++++++++---- 4 files changed, 60 insertions(+), 34 deletions(-) diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index b284c44..45ee902 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -5,26 +5,26 @@ import { syncWakatimeData, validateWakatimeApiKey, } from "@/app/lib/wakatime/sync"; +import { auth } from "@/app/lib/auth"; -export async function GET(request: Request) { - const { user } = await getCurrentUser(); - const { searchParams } = new URL(request.url); - const apiKey = searchParams.get("apiKey") || ""; - const saveOnly = - searchParams.get("saveOnly") === "1" || - searchParams.get("saveOnly") === "true"; +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { apiKey, saveOnly } = await req.json(); const validationError = validateWakatimeApiKey(apiKey); if (validationError) { return NextResponse.json({ error: validationError }, { status: 400 }); } - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (saveOnly) { - const result = await saveWakatimeApiKey({ userId: user.id, apiKey }); + const result = await saveWakatimeApiKey({ + userId: session.user.id, + apiKey, + }); if (!result.success) { return NextResponse.json( @@ -37,9 +37,9 @@ export async function GET(request: Request) { } const result = await syncWakatimeData({ - userId: user.id, + userId: session.user.id, incomingApiKey: apiKey, - storedApiKey: user.wakatime_api_key, + storedApiKey: session.user.wakatime_api_key, }); if (!result.success && result.status !== 200) { diff --git a/app/components/auth/VerifyWakatime.tsx b/app/components/auth/VerifyWakatime.tsx index e3818c8..2fdcd28 100644 --- a/app/components/auth/VerifyWakatime.tsx +++ b/app/components/auth/VerifyWakatime.tsx @@ -43,9 +43,11 @@ export default function VerifyWakatime() { const verifyWakatimePromise = new Promise(async (resolve, reject) => { try { - const wakatimeSyncResponse = await fetch( - `/api/wakatime/sync?apiKey=${encodeURIComponent(apiKey)}`, - ); + const wakatimeSyncResponse = await fetch(`/api/wakatime/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey, saveOnly: true }), + }); if (!wakatimeSyncResponse.ok) throw new Error("Failed to sync Wakatime."); @@ -86,7 +88,12 @@ export default function VerifyWakatime() { href="/" className="flex items-center gap-3 w-fit hover:opacity-80 transition" > - Devpulse Logo + Devpulse Logo Devpulse @@ -152,7 +159,12 @@ export default function VerifyWakatime() { href="/" className="lg:hidden flex items-center justify-center gap-3 mb-10" > - Devpulse Logo + Devpulse Logo

Devpulse

diff --git a/app/components/dashboard/Settings/WakaTimeKey.tsx b/app/components/dashboard/Settings/WakaTimeKey.tsx index 4e1be67..7110855 100644 --- a/app/components/dashboard/Settings/WakaTimeKey.tsx +++ b/app/components/dashboard/Settings/WakaTimeKey.tsx @@ -42,16 +42,13 @@ export default function WakaTimeKey({ const updateKey = new Promise(async (resolve, reject) => { try { - const response = await fetch( - `/api/wakatime/sync?apiKey=${encodeURIComponent(nextKey)}&saveOnly=1`, - ); - const payload = (await response.json()) as { error?: string }; - - if (!response.ok) { - return reject( - new Error(payload.error || "Failed to update API key."), - ); - } + const wakatimeSyncResponse = await fetch(`/api/wakatime/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: nextKey, saveOnly: true }), + }); + if (!wakatimeSyncResponse.ok) + throw new Error("Failed to sync Wakatime."); resolve(); } catch (error) { diff --git a/app/lib/proxy/rate-limiter.ts b/app/lib/proxy/rate-limiter.ts index 25a6fa3..9c117e2 100644 --- a/app/lib/proxy/rate-limiter.ts +++ b/app/lib/proxy/rate-limiter.ts @@ -1,6 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import { checkRateLimit } from "../../utils/rate-limit"; +/* + * Gets the rate limit for a given route based on pathname and method. + * + * @param pathname The pathname of the request. + * @param method The HTTP method of the request. + * @returns An object containing the maximum number of requests and the window duration in milliseconds. + */ +function getLimitsForRoute(pathname: string, method: string) { + const isAuthEndpoint = /api\/(login|signup)/.test(pathname); + if (isAuthEndpoint) return { max: 10, windowMs: 60 * 60 * 1000 }; // thats 1 hour for 10 requests + + const isMutating = ["POST", "PUT", "PATCH", "DELETE"].includes(method); + if (isMutating) return { max: 20, windowMs: 5 * 60 * 1000 }; // thats 5 minutes for 20 requests + + return { max: 100, windowMs: 5 * 60 * 1000 }; // thats 5 minutes for 100 requests +} + export default function RateLimiter( request: NextRequest, ): NextResponse | undefined { @@ -27,11 +44,11 @@ export default function RateLimiter( ); } - const isAuthEndpoint = /api\/(login|signup)/.test(request.nextUrl.pathname); - const maxRequests = isAuthEndpoint ? 5 : 10; - const windowMs = isAuthEndpoint ? 60 * 60 * 1000 : 5 * 60 * 1000; - - const withinLimit = checkRateLimit(ip, maxRequests, windowMs); + const { max, windowMs } = getLimitsForRoute( + request.nextUrl.pathname, + request.method, + ); + const withinLimit = checkRateLimit(ip, max, windowMs); if (!withinLimit) { return NextResponse.json({ error: "Too many requests" }, { status: 429 }); From 368f3f2f944f436ac81501da3c35ef7e57f7079b Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Thu, 27 Aug 2026 02:50:55 +0800 Subject: [PATCH 2/4] fix: route bug fixes --- app/api/conversations/[id]/presence/route.ts | 5 ++--- app/api/conversations/route.ts | 10 +++++----- app/api/wakatime/sync/route.ts | 10 +++++----- app/components/Chat.tsx | 4 +++- app/components/auth/VerifyWakatime.tsx | 2 +- .../chat/hooks/useChatConversationActions.ts | 4 ++-- app/components/chat/hooks/useChatMessageComposer.ts | 2 +- app/components/chat/hooks/useChatPresence.ts | 2 +- app/components/dashboard/Settings/WakaTimeKey.tsx | 2 +- 9 files changed, 21 insertions(+), 20 deletions(-) diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts index 45cf90a..c00d13d 100644 --- a/app/api/conversations/[id]/presence/route.ts +++ b/app/api/conversations/[id]/presence/route.ts @@ -12,15 +12,14 @@ export async function PATCH( } const { id: conversationId } = await params; - const body = await req.json().catch(() => ({})); - const { markRead } = body as { markRead?: boolean }; + const { mark_read } = await req.json(); const timestamp = new Date(); const data: { lastSeenAt: Date; lastReadAt?: Date } = { lastSeenAt: timestamp, }; - if (markRead) { + if (mark_read) { data.lastReadAt = timestamp; } diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts index c541544..ca8395a 100644 --- a/app/api/conversations/route.ts +++ b/app/api/conversations/route.ts @@ -47,11 +47,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { otherUserId, otherUserEmail } = await req.json(); + const { other_user_id, other_user_email } = await req.json(); - if (!otherUserId) { + if (!other_user_id) { return NextResponse.json( - { error: "otherUserId is required." }, + { error: "other_user_id is required." }, { status: 400 }, ); } @@ -71,8 +71,8 @@ export async function POST(req: Request) { last_read_at: timestamp, }, { - user_id: otherUserId, - email: otherUserEmail ?? "", + user_id: other_user_id, + email: other_user_email ?? "", last_seen_at: EPOCH, last_read_at: EPOCH, }, diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index 45ee902..74d142e 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -13,17 +13,17 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { apiKey, saveOnly } = await req.json(); + const { api_key, save_only } = await req.json(); - const validationError = validateWakatimeApiKey(apiKey); + const validationError = validateWakatimeApiKey(api_key); if (validationError) { return NextResponse.json({ error: validationError }, { status: 400 }); } - if (saveOnly) { + if (save_only) { const result = await saveWakatimeApiKey({ userId: session.user.id, - apiKey, + apiKey: api_key, }); if (!result.success) { @@ -38,7 +38,7 @@ export async function POST(req: Request) { const result = await syncWakatimeData({ userId: session.user.id, - incomingApiKey: apiKey, + incomingApiKey: api_key, storedApiKey: session.user.wakatime_api_key, }); diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 168d7a3..57aacd7 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -841,7 +841,9 @@ export default function Chat({ user }: { user: ChatUserShape }) { {showModal && (
-

New Message

+

+ New Message +

setSearch(e.target.value)} diff --git a/app/components/auth/VerifyWakatime.tsx b/app/components/auth/VerifyWakatime.tsx index 2fdcd28..8aafc2f 100644 --- a/app/components/auth/VerifyWakatime.tsx +++ b/app/components/auth/VerifyWakatime.tsx @@ -46,7 +46,7 @@ export default function VerifyWakatime() { const wakatimeSyncResponse = await fetch(`/api/wakatime/sync`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey, saveOnly: true }), + body: JSON.stringify({ api_key: apiKey, save_only: true }), }); if (!wakatimeSyncResponse.ok) throw new Error("Failed to sync Wakatime."); diff --git a/app/components/chat/hooks/useChatConversationActions.ts b/app/components/chat/hooks/useChatConversationActions.ts index 8d3540f..4f4deca 100644 --- a/app/components/chat/hooks/useChatConversationActions.ts +++ b/app/components/chat/hooks/useChatConversationActions.ts @@ -71,8 +71,8 @@ export function useChatConversationActions({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - otheruser_id: otherUser.user_id, - otherUserEmail: otherUser.email, + other_user_id: otherUser.user_id, + other_user_email: otherUser.email, }), }); diff --git a/app/components/chat/hooks/useChatMessageComposer.ts b/app/components/chat/hooks/useChatMessageComposer.ts index 26f2ebd..b30a335 100644 --- a/app/components/chat/hooks/useChatMessageComposer.ts +++ b/app/components/chat/hooks/useChatMessageComposer.ts @@ -86,7 +86,7 @@ export function useChatMessageComposer({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - conversationId: targetConversationId, + conversation_id: targetConversationId, text: outgoingText, attachments: [], }), diff --git a/app/components/chat/hooks/useChatPresence.ts b/app/components/chat/hooks/useChatPresence.ts index eea2cee..13d2b2d 100644 --- a/app/components/chat/hooks/useChatPresence.ts +++ b/app/components/chat/hooks/useChatPresence.ts @@ -101,7 +101,7 @@ export function useChatPresence({ await fetch(`/api/conversations/${targetConversationId}/presence`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ markRead: true }), + body: JSON.stringify({ mark_read: true }), }).catch(() => {}); }, [ diff --git a/app/components/dashboard/Settings/WakaTimeKey.tsx b/app/components/dashboard/Settings/WakaTimeKey.tsx index 7110855..5f98e97 100644 --- a/app/components/dashboard/Settings/WakaTimeKey.tsx +++ b/app/components/dashboard/Settings/WakaTimeKey.tsx @@ -45,7 +45,7 @@ export default function WakaTimeKey({ const wakatimeSyncResponse = await fetch(`/api/wakatime/sync`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey: nextKey, saveOnly: true }), + body: JSON.stringify({ api_key: key, save_only: true }), }); if (!wakatimeSyncResponse.ok) throw new Error("Failed to sync Wakatime."); From a6189d1f38f239ee4b1bf1a4c767f81742ffb4ef Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Thu, 27 Aug 2026 02:57:57 +0800 Subject: [PATCH 3/4] fix: bug fixes and performance improvements --- app/api/wakatime/sync/route.ts | 27 +++++++++++- app/layout.tsx | 2 +- app/lib/wakatime/sync.ts | 76 +++++++++++++++++----------------- 3 files changed, 66 insertions(+), 39 deletions(-) diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index 74d142e..87f6299 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { getCurrentUser } from "@/app/lib/auth/user"; import { saveWakatimeApiKey, syncWakatimeData, @@ -7,6 +6,32 @@ import { } from "@/app/lib/wakatime/sync"; import { auth } from "@/app/lib/auth"; +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await syncWakatimeData({ + userId: session.user.id, + incomingApiKey: "", + storedApiKey: session.user.wakatime_api_key ?? undefined, + }); + + if (!result.success && result.status !== 200) { + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); + } + + return NextResponse.json({ + success: result.success, + data: result.data, + error: result.error, + }); +} + export async function POST(req: Request) { const session = await auth(); if (!session?.user?.id) { diff --git a/app/layout.tsx b/app/layout.tsx index 96c67f9..6e68104 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -105,7 +105,7 @@ export default function RootLayout({ > - + = CONSISTENCY_DAYS ) { - return { - status: 200, - success: true, - data: serializeBigInts(existing), - }; + return { status: 200, success: true, data: serializeBigInts(existing) }; } } } const { startStr, endStr } = getWindowRange(); - const waka = await fetchWakatimeData(resolvedApiKey, startStr, endStr); + + const keyUpdatePromise = normalizedIncomingApiKey + ? updateProfileWakatimeApiKey(userId, normalizedIncomingApiKey).then( + () => ({ ok: true as const }), + (err) => ({ ok: false as const, err }), + ) + : Promise.resolve({ ok: true as const }); + + const [waka, keyUpdateResult] = await Promise.all([ + fetchWakatimeData(resolvedApiKey, startStr, endStr), + keyUpdatePromise, + ]); + + if (!keyUpdateResult.ok) { + const err = keyUpdateResult.err; + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return { + status: 400, + success: false, + error: "This WakaTime API key is already in use.", + }; + } + return { status: 500, success: false, error: "Failed to update API key" }; + } if (!waka.ok || !waka.stats || !waka.summaries) { return { @@ -199,24 +221,6 @@ export async function syncWakatimeData({ }; } - if (normalizedIncomingApiKey) { - try { - await updateProfileWakatimeApiKey(userId, normalizedIncomingApiKey); - } catch (err) { - if ( - err instanceof Prisma.PrismaClientKnownRequestError && - err.code === "P2002" - ) { - return { - status: 400, - success: false, - error: "This WakaTime API key is already in use.", - }; - } - return { status: 500, success: false, error: "Failed to update API key" }; - } - } - const dailyStats = waka.summaries.map((day) => ({ date: toDateKey(day.range.date), total_seconds: Math.floor(day.grand_total.total_seconds || 0), @@ -251,15 +255,7 @@ export async function syncWakatimeData({ projects: (waka.stats.projects || []) as Prisma.InputJsonValue, last_fetched_at: new Date(nowIso), }), - ]); - - const mergedResult = { - ...statsResult, - projects: projectsResult?.projects || [], - }; - - try { - await upsertUserDashboardSnapshot({ + upsertUserDashboardSnapshot({ user_id: userId, snapshot_date: new Date(endStr), total_seconds_7d: BigInt(snapshotMetrics.totalSeconds7d), @@ -277,10 +273,16 @@ export async function syncWakatimeData({ ? new Prisma.Decimal(topLanguage.percent.toFixed(2)) : null, updated_at: new Date(nowIso), - }); - } catch (err) { - console.error("Failed to upsert user dashboard snapshot", err); - } + }).catch((err) => { + console.error("Failed to upsert user dashboard snapshot", err); + return null; + }), + ]); + + const mergedResult = { + ...statsResult, + projects: projectsResult?.projects || [], + }; return { status: 200, From 3e727d4e4bc5c99a833a339825dca094e245bcfd Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Thu, 27 Aug 2026 03:26:37 +0800 Subject: [PATCH 4/4] feat(chat): bug fixes in UI and components --- app/api/conversations/[id]/presence/route.ts | 6 ++-- app/api/messages/route.ts | 14 ++++---- app/components/Chat.tsx | 35 +++++++------------- app/components/chat/Conversations.tsx | 12 ++----- app/components/chat/Messages.tsx | 4 +-- app/utils/time.ts | 20 ++++++----- 6 files changed, 38 insertions(+), 53 deletions(-) diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts index c00d13d..8259408 100644 --- a/app/api/conversations/[id]/presence/route.ts +++ b/app/api/conversations/[id]/presence/route.ts @@ -16,11 +16,11 @@ export async function PATCH( const timestamp = new Date(); - const data: { lastSeenAt: Date; lastReadAt?: Date } = { - lastSeenAt: timestamp, + const data: { last_seen_at: Date; last_read_at?: Date } = { + last_seen_at: timestamp, }; if (mark_read) { - data.lastReadAt = timestamp; + data.last_read_at = timestamp; } await prisma.conversationParticipant.updateMany({ diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 360bbae..66b3955 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -58,10 +58,10 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { conversationId, text, attachments } = await req.json(); + const { conversation_id, text, attachments } = await req.json(); if ( - !conversationId || + !conversation_id || (!text?.trim() && (!attachments || attachments.length === 0)) ) { return NextResponse.json( @@ -73,7 +73,7 @@ export async function POST(req: Request) { const participant = await prisma.conversationParticipant.findUnique({ where: { conversation_id_user_id: { - conversation_id: conversationId, + conversation_id, user_id: session.user.id, }, }, @@ -85,7 +85,7 @@ export async function POST(req: Request) { const message = await prisma.message.create({ data: { - conversation_id: conversationId, + conversation_id, sender_id: session.user.id, text: text?.trim() ?? "", attachments: attachments ?? [], @@ -101,11 +101,11 @@ export async function POST(req: Request) { created_at: message.created_at.toISOString(), }; - emitter.emit(`chat:${conversationId}`, { type: "message", data: payload }); + emitter.emit(`chat:${conversation_id}`, { type: "message", data: payload }); const participants = await prisma.conversationParticipant.findMany({ where: { - conversation_id: conversationId, + conversation_id, user_id: { not: session.user.id }, }, select: { user_id: true }, @@ -114,7 +114,7 @@ export async function POST(req: Request) { for (const p of participants) { emitter.emit(`user:${p.user_id}`, { type: "new_message", - data: { conversation_id: conversationId, sender_id: session.user.id }, + data: { conversation_id, sender_id: session.user.id }, }); } diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 57aacd7..3062001 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -302,19 +302,18 @@ export default function Chat({ user }: { user: ChatUserShape }) { : undefined; const activeLabel = isGlobalActive - ? "Global Chat" + ? "Global" : activeOtherUser?.email?.split("@")[0] || "Unknown"; const activeSublabel = isGlobalActive - ? "Public Channel" + ? "Worldwide" : activeOtherUserOnline ? "Online" : "Offline"; - const activeSublabelClass = - activeOtherUserOnline || isGlobalActive - ? "text-emerald-600" - : "text-gray-500"; + const activeSublabelClass = activeOtherUserOnline + ? "text-emerald-600" + : "text-gray-500"; const typingIndicatorText = activeTypingState ? isGlobalActive @@ -355,15 +354,15 @@ export default function Chat({ user }: { user: ChatUserShape }) { attachments={allMediaAttachments} onChange={setMediaViewer} /> -
+
{/* Left Sidebar */}

- Message category + Messages

-
-
- {activeInitials} -
- {!isGlobalActive && activeOtherUserOnline && ( -
- )} -

{activeLabel} @@ -682,7 +671,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { {/* Right Sidebar */} {conversationId && (