Skip to content

Commit b923776

Browse files
committed
Merge branch 'main' into support-cache-busting-querys
# Conflicts: # middleware.ts
2 parents 05266c3 + 31b8454 commit b923776

175 files changed

Lines changed: 13229 additions & 1408 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/issue-management/stale-assignment.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async function handleStaleAssignments({ github, context, core }) {
2929

3030
if (timeSinceUpdate > TWO_DAYS_MS) {
3131
const currentAssignees = issue.assignees.map((a) => a.login);
32+
if (currentAssignees.length === 0) continue;
3233

3334
// Check if any open PRs reference this issue before unassigning
3435
const { data: searchResult } = await github.rest.search.issuesAndPullRequests({
@@ -46,24 +47,22 @@ async function handleStaleAssignments({ github, context, core }) {
4647
`Issue #${issue.number} has been inactive since ${issue.updated_at}. Removing assignees.`
4748
);
4849

49-
if (currentAssignees.length > 0) {
50-
await github.rest.issues.removeAssignees({
51-
owner,
52-
repo,
53-
issue_number: issue.number,
54-
assignees: currentAssignees,
55-
});
56-
57-
// 2. Post a comment
58-
await github.rest.issues.createComment({
59-
owner,
60-
repo,
61-
issue_number: issue.number,
62-
body: `⚠️ Assignment automatically removed due to inactivity.\nFeel free to reclaim the issue if you want to continue working on it.`,
63-
});
64-
65-
staleCount++;
66-
}
50+
await github.rest.issues.removeAssignees({
51+
owner,
52+
repo,
53+
issue_number: issue.number,
54+
assignees: currentAssignees,
55+
});
56+
57+
// 2. Post a comment
58+
await github.rest.issues.createComment({
59+
owner,
60+
repo,
61+
issue_number: issue.number,
62+
body: `⚠️ Assignment automatically removed due to inactivity.\nFeel free to reclaim the issue if you want to continue working on it.`,
63+
});
64+
65+
staleCount++;
6766
}
6867
}
6968

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package-lock.json

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ Transform your GitHub contribution history into a cinematic 3D monolith.
111111
![CommitPulse](https://commitpulse.vercel.app/api/streak?user=jhasourav07&bg=0a0a0a&accent=ff6b35&text=ffffff)
112112
```
113113

114+
#### 📅 Monthly Summary
115+
116+
```md
117+
![CommitPulse Monthly](https://commitpulse.vercel.app/api/streak?user=octocat&view=monthly)
118+
```
119+
114120
---
115121

116122
## 📚 Documentation Index
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import DashboardSkeleton from '@/components/dashboard/DashboardSkeleton';
2+
3+
export default function DashboardUserLoading() {
4+
return (
5+
<div className="p-4 md:p-6 lg:p-8 min-h-screen bg-black text-white">
6+
<DashboardSkeleton />
7+
</div>
8+
);
9+
}

app/(root)/dashboard/error.test.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,34 @@ import { render, screen, fireEvent } from '@testing-library/react';
33
import { describe, it, expect, vi } from 'vitest';
44
import ErrorPage from './error';
55

6-
import type { ReactNode } from 'react';
7-
86
vi.mock('next/link', () => ({
9-
default: ({ children, href }: { children: ReactNode; href: string }) => (
10-
<a href={href}>{children}</a>
7+
default: ({
8+
children,
9+
...props
10+
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children: React.ReactNode }) => (
11+
<a {...props}>{children}</a>
1112
),
1213
}));
1314

1415
describe('Dashboard Error Page', () => {
15-
it('renders API limit UI', () => {
16+
it('renders the API limit emoji for API limit reached errors', () => {
1617
render(<ErrorPage error={new Error('API limit reached')} reset={vi.fn()} />);
1718

1819
expect(screen.getByRole('heading', { name: 'API Limit Reached' })).toBeInTheDocument();
20+
expect(screen.getByText('⏳')).toBeInTheDocument();
1921
});
2022

21-
it('renders not found UI', () => {
22-
render(<ErrorPage error={new Error('not found')} reset={vi.fn()} />);
23+
it('renders the not found emoji for User not found errors', () => {
24+
render(<ErrorPage error={new Error('User not found')} reset={vi.fn()} />);
2325

2426
expect(screen.getByText(/not found/i)).toBeInTheDocument();
27+
expect(screen.getByText('🕵️‍♂️')).toBeInTheDocument();
2528
});
2629

27-
it('renders generic error UI', () => {
28-
render(<ErrorPage error={new Error('something went wrong')} reset={vi.fn()} />);
30+
it('renders the generic error emoji for other errors', () => {
31+
render(<ErrorPage error={new Error('Something went wrong')} reset={vi.fn()} />);
2932

30-
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
33+
expect(screen.getByText('⚠️')).toBeInTheDocument();
3134
});
3235

3336
it('shows Try again button', () => {

app/(root)/dashboard/loading.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ describe('DashboardLoading', () => {
2020
expect(container).toBeTruthy();
2121
});
2222

23-
it('renders 2 StatsCardSkeleton components', () => {
23+
it('renders 3 StatsCardSkeleton components', () => {
2424
render(<DashboardLoading />);
2525
const skeletons = screen.getAllByTestId('stats-card-skeleton');
26-
expect(skeletons).toHaveLength(2);
26+
expect(skeletons).toHaveLength(3);
2727
});
2828

2929
it('renders shimmer skeleton elements in the left sidebar', () => {

app/(root)/dashboard/loading.tsx

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,9 @@
1-
import StatsCardSkeleton from '@/components/dashboard/StatsCardSkeleton';
2-
import AchievementsSkeleton from '@/components/dashboard/AchievementsSkeleton';
3-
import AIInsightsSkeleton from '@/components/dashboard/AIInsightsSkeleton';
1+
import DashboardSkeleton from '@/components/dashboard/DashboardSkeleton';
42

53
export default function DashboardLoading() {
64
return (
75
<div className="p-4 md:p-6 lg:p-8 min-h-screen bg-black text-white">
8-
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
9-
{/* Left Sidebar Skeleton */}
10-
<div className="flex flex-col gap-6">
11-
{/* Profile Card Skeleton Placeholder */}
12-
<div className="h-100 rounded-2xl shimmer border border-white/10" />
13-
14-
{/* Achievements Skeleton */}
15-
<div className="p-6 rounded-xl bg-[#0a0a0a] border border-[rgba(255,255,255,0.08)]">
16-
<div className="flex items-center gap-2.5 mb-5">
17-
<div className="w-4 h-4 shimmer rounded" />
18-
<div className="w-24 h-4 shimmer rounded" />
19-
</div>
20-
<AchievementsSkeleton />
21-
</div>
22-
</div>
23-
24-
{/* Main Content Skeleton */}
25-
<div className="flex flex-col gap-6 lg:gap-8">
26-
{/* Hero/Profile section */}
27-
<div className="h-64 rounded-2xl shimmer border border-white/10" />
28-
29-
{/* Stats Cards Grid - 2 cards */}
30-
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
31-
<StatsCardSkeleton />
32-
<StatsCardSkeleton />
33-
</div>
34-
35-
{/* Bottom section */}
36-
<div className="h-48 rounded-2xl shimmer border border-white/10" />
37-
</div>
38-
39-
{/* Right Sidebar Skeleton */}
40-
<div className="flex flex-col gap-6">
41-
<div className="h-24 rounded-2xl shimmer border border-white/10" />
42-
<div className="h-24 rounded-2xl shimmer border border-white/10" />
43-
<div className="h-24 rounded-2xl shimmer border border-white/10" />
44-
<AIInsightsSkeleton />
45-
</div>
46-
</div>
6+
<DashboardSkeleton />
477
</div>
488
);
499
}

app/api/notify/route.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,19 @@ vi.mock('@/models/Notification', () => ({
1212
},
1313
}));
1414
vi.mock('@/lib/rate-limit', () => ({
15+
getRateLimitHeaders: vi.fn((result) => ({
16+
'X-RateLimit-Limit': result.limit.toString(),
17+
'X-RateLimit-Remaining': result.remaining.toString(),
18+
'X-RateLimit-Reset': result.reset.toString(),
19+
})),
1520
notifyRateLimiter: {
1621
check: vi.fn().mockResolvedValue(true),
22+
checkWithResult: vi.fn().mockResolvedValue({
23+
success: true,
24+
limit: 5,
25+
remaining: 4,
26+
reset: Date.now() + 60000,
27+
}),
1728
},
1829
}));
1930
vi.mock('@/services/github/validate-user', () => ({
@@ -102,9 +113,18 @@ describe('POST /api/notify', () => {
102113
// ── Rate limiting ────────────────────────────────────────────────────────
103114

104115
it('returns 429 when rate limited', async () => {
105-
vi.mocked(notifyRateLimiter.check).mockResolvedValue(false);
116+
const reset = Date.now() + 60000;
117+
vi.mocked(notifyRateLimiter.checkWithResult).mockResolvedValueOnce({
118+
success: false,
119+
limit: 5,
120+
remaining: 0,
121+
reset,
122+
});
106123
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'a@b.com' }));
107124
expect(res.status).toBe(429);
125+
expect(res.headers.get('x-ratelimit-limit')).toBe('5');
126+
expect(res.headers.get('x-ratelimit-remaining')).toBe('0');
127+
expect(res.headers.get('x-ratelimit-reset')).toBe(reset.toString());
108128
});
109129

110130
// ── Per-username write cooldown ───────────────────────────────────────────
@@ -231,9 +251,18 @@ describe('GET /api/notify', () => {
231251
// ── Rate limiting ────────────────────────────────────────────────────────
232252

233253
it('returns 429 when rate limited', async () => {
234-
vi.mocked(notifyRateLimiter.check).mockResolvedValue(false);
254+
const reset = Date.now() + 60000;
255+
vi.mocked(notifyRateLimiter.checkWithResult).mockResolvedValueOnce({
256+
success: false,
257+
limit: 5,
258+
remaining: 0,
259+
reset,
260+
});
235261
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
236262
expect(res.status).toBe(429);
263+
expect(res.headers.get('x-ratelimit-limit')).toBe('5');
264+
expect(res.headers.get('x-ratelimit-remaining')).toBe('0');
265+
expect(res.headers.get('x-ratelimit-reset')).toBe(reset.toString());
237266
});
238267

239268
// ── MONGODB_URI handling ──────────────────────────────────────────────────

app/api/notify/route.ts

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { NextResponse } from 'next/server';
1+
import { NextRequest, NextResponse } from 'next/server';
22
import dbConnect from '@/lib/mongodb';
33
import { Notification } from '@/models/Notification';
44
import { notifyPostSchema, notifyGetSchema } from '@/lib/validations';
5-
import { notifyRateLimiter } from '@/lib/rate-limit';
65
import { getClientIp } from '@/utils/getClientIp';
76
import { DistributedCache } from '@/lib/cache';
87
import { gitHubUserValidator } from '@/services/github/validate-user';
8+
import { getRateLimitHeaders, notifyRateLimiter } from '@/lib/rate-limit';
99

1010
const notifyWriteCache = new DistributedCache<number>(5000, 60000);
1111
const NOTIFY_WRITE_COOLDOWN_MS = 5 * 60 * 1000;
@@ -44,11 +44,12 @@ export async function POST(req: Request) {
4444
// fallback ensures rate limit is ALWAYS applied
4545
const rateLimitKey =
4646
ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`;
47+
const rateLimitResult = await notifyRateLimiter.checkWithResult(rateLimitKey);
4748

48-
if (!(await notifyRateLimiter.check(rateLimitKey))) {
49+
if (!rateLimitResult.success) {
4950
return NextResponse.json(
5051
{ success: false, message: 'Too many requests, please try again later.' },
51-
{ status: 429 }
52+
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
5253
);
5354
}
5455

@@ -172,19 +173,102 @@ export async function POST(req: Request) {
172173
}
173174
}
174175

175-
// ─── GET /api/notify ─────────────────────────────────────────────────────────
176-
// Fetch notification preferences for a user
177-
export async function GET(req: Request) {
176+
// ─── DELETE /api/notify ──────────────────────────────────────────────────────
177+
// Remove notification preferences for a user (unsubscribe / right to erasure)
178+
export async function DELETE(req: NextRequest) {
178179
// Rate limiting
179180
const ip = getClientIp(req);
180181

181-
if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
182+
const rateLimitKey =
183+
ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`;
184+
185+
if (!(await notifyRateLimiter.check(rateLimitKey))) {
182186
return NextResponse.json(
183187
{ success: false, message: 'Too many requests, please try again later.' },
184188
{ status: 429 }
185189
);
186190
}
187191

192+
// Validate query params with Zod (reuse notifyGetSchema — expects ?user=)
193+
const { searchParams } = new URL(req.url);
194+
const parsed = notifyGetSchema.safeParse({
195+
user: searchParams.get('user') ?? undefined,
196+
});
197+
198+
if (!parsed.success) {
199+
const fieldErrors = parsed.error.flatten();
200+
const firstError =
201+
Object.values(fieldErrors.fieldErrors).flat()[0] ??
202+
fieldErrors.formErrors[0] ??
203+
'Invalid request parameters.';
204+
return NextResponse.json({ success: false, message: firstError }, { status: 400 });
205+
}
206+
207+
const { user: username } = parsed.data;
208+
209+
try {
210+
// Graceful MONGODB_URI handling
211+
if (!process.env.MONGODB_URI) {
212+
if (process.env.NODE_ENV === 'production') {
213+
console.error(
214+
'CRITICAL: MONGODB_URI is not set in production environment. Notification deletion is disabled.'
215+
);
216+
return NextResponse.json(
217+
{ success: false, message: 'Database configuration error.' },
218+
{ status: 500 }
219+
);
220+
}
221+
222+
console.warn(
223+
'MONGODB_URI is not set. Bypassing notification deletion for local development.'
224+
);
225+
return NextResponse.json({
226+
success: true,
227+
message: 'Notification deletion bypassed (no database configured).',
228+
});
229+
}
230+
231+
await dbConnect();
232+
233+
const result = await Notification.deleteOne({ username: username.toLowerCase() });
234+
235+
if (result.deletedCount === 0) {
236+
return NextResponse.json(
237+
{ success: false, message: 'No notification preferences found for this user.' },
238+
{ status: 404 }
239+
);
240+
}
241+
242+
return NextResponse.json(
243+
{ success: true, message: 'Notification preferences deleted successfully.' },
244+
{ status: 200 }
245+
);
246+
} catch (error) {
247+
console.error('[/api/notify] Error deleting notification preferences:', error);
248+
return NextResponse.json(
249+
{ success: false, message: 'Internal server error.' },
250+
{ status: 500 }
251+
);
252+
}
253+
}
254+
255+
// ─── GET /api/notify ─────────────────────────────────────────────────────────
256+
// Fetch notification preferences for a user
257+
export async function GET(req: Request) {
258+
// Rate limiting
259+
const ip = getClientIp(req);
260+
261+
if (ip !== 'unknown') {
262+
const rateLimitResult = await notifyRateLimiter.checkWithResult(ip);
263+
264+
if (!rateLimitResult.success) {
265+
return NextResponse.json(
266+
{ success: false, message: 'Too many requests, please try again later.' },
267+
{ status: 429, headers: getRateLimitHeaders(rateLimitResult) }
268+
);
269+
}
270+
}
271+
188272
// Validate query params with Zod
189273
const { searchParams } = new URL(req.url);
190274
const parsed = notifyGetSchema.safeParse({

0 commit comments

Comments
 (0)