From 337ba0ac82239eaf31d535d09833b6bbe0dee729 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:06:46 +0530
Subject: [PATCH 01/41] fix: update Node.js prerequisite from >=18 to >=20 in
DEVELOPMENT.md (#2209)
---
DEVELOPMENT.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 0dd94c99b..e466550f0 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -8,7 +8,7 @@ Everything you need to run DevTrack locally from scratch in under 10 minutes.
| Tool | Version | Check |
|------|---------|-------|
-| Node.js | >= 18 | `node -v` |
+| Node.js | >= 20 | `node -v` |
| npm | >= 9 | `npm -v` |
| Git | any | `git --version` |
From 69e16fec1c1346e2355a61a91dcf9d15a9d0f8c7 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:06:55 +0530
Subject: [PATCH 02/41] fix: improve pull request template clarity (#2177)
---
.github/pull_request_template.md | 78 ++++++++++++++++++++------------
1 file changed, 48 insertions(+), 30 deletions(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index f5a79033b..c2fa23a3b 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,65 +1,83 @@
## Summary
-Enhanced the existing `PULL_REQUEST_TEMPLATE.md` to improve contribution quality, accessibility awareness, and reviewer guidance.
+
-Closes #1107
+Closes #
---
## Type of Change
-- [ ] Bug fix
-- [ ] New feature
-- [x] Documentation update
-- [ ] Refactor / code cleanup
+
+
+- [ ] ๐ Bug fix (non-breaking change that fixes an issue)
+- [ ] โจ New feature (non-breaking change that adds functionality)
+- [ ] ๐ฅ Breaking change (fix or feature that changes existing behavior)
+- [ ] ๐ Documentation update
+- [ ] โป๏ธ Refactor / code cleanup (no functional change)
+- [ ] โก Performance improvement
+- [ ] ๐ Security fix
+- [ ] ๐งช Tests only
---
-## Changes Made
+## What Changed
+
+
-- Improved PR template structure and readability
-- Added accessibility checklist section
-- Added additional notes section
-- Enhanced contributor guidance for testing and review
-- Improved consistency for future pull requests
+-
+-
+-
---
## How to Test
-Steps for the reviewer to verify this works:
+
+
+1.
+2.
+3.
-1. Create a new pull request
-2. Verify the updated PR template appears automatically
-3. Check that all checklist sections render properly
-4. Ensure markdown formatting works correctly
+**Expected result:**
---
-## Screenshots (if UI change)
+## Screenshots / Recordings
-N/A
+
+
+| Before | After |
+|--------|-------|
+| | |
---
## Checklist
-- [x] Linked issue in summary
-- [x] `npm run lint` passes locally
-- [x] No TypeScript errors (`npm run type-check`)
-- [x] Self-reviewed the diff
-- [ ] Added/updated tests if applicable
+
+
+- [ ] Linked the related issue above
+- [ ] Self-reviewed my own diff
+- [ ] No unnecessary `console.log`, debug code, or commented-out blocks
+- [ ] `npm run lint` passes locally
+- [ ] No TypeScript errors (`npm run type-check`)
+- [ ] Added or updated tests where applicable
+- [ ] Updated documentation / comments if behavior changed
---
-## Accessibility Checklist
+## Accessibility (UI changes only)
+
+
-- [x] Proper keyboard navigation tested
-- [x] Responsive UI verified
-- [x] Accessibility labels added where needed
+- [ ] Keyboard navigation works correctly
+- [ ] Color contrast meets WCAG AA standard
+- [ ] ARIA labels / roles added where needed
+- [ ] Tested on mobile / responsive layout
---
-## Additional Notes
+## Additional Context
-This update standardizes pull request submissions and helps maintain consistent review quality across contributions.
+
\ No newline at end of file
From 605fed5f7b2b49e2767d3cd716837f5a0e92677b Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:07:25 +0530
Subject: [PATCH 03/41] docs: add GSSoC husky troubleshooting guide (#2194)
---
docs/HUSKY_TROUBLESHOOTING.md | 239 ++++++++++++++++++++++++++++++++++
1 file changed, 239 insertions(+)
create mode 100644 docs/HUSKY_TROUBLESHOOTING.md
diff --git a/docs/HUSKY_TROUBLESHOOTING.md b/docs/HUSKY_TROUBLESHOOTING.md
new file mode 100644
index 000000000..f8137dfc7
--- /dev/null
+++ b/docs/HUSKY_TROUBLESHOOTING.md
@@ -0,0 +1,239 @@
+# ๐ถ Husky Troubleshooting Reference Manual (GSSoC Contributors)
+
+This guide helps GSSoC contributors resolve common Husky and Git hook issues encountered during local development on DevTrack.
+
+---
+
+## ๐ Table of Contents
+
+1. [What is Husky?](#what-is-husky)
+2. [Common Errors & Fixes](#common-errors--fixes)
+3. [Pre-commit Hook Failures](#pre-commit-hook-failures)
+4. [Pre-push Hook Failures](#pre-push-hook-failures)
+5. [Husky Not Running At All](#husky-not-running-at-all)
+6. [Windows-Specific Issues](#windows-specific-issues)
+7. [Nuclear Reset](#nuclear-reset)
+8. [Quick Reference](#quick-reference)
+
+---
+
+## What is Husky?
+
+Husky is a tool that runs scripts automatically before Git actions like `commit` and `push`. DevTrack uses Husky to enforce:
+
+- **ESLint** checks before every commit
+- **TypeScript** type checking before push
+- **Prettier** formatting validation
+
+This ensures all code merged into `main` meets quality standards.
+
+---
+
+## Common Errors & Fixes
+
+### โ Error: `husky: command not found`
+
+**Cause:** Dependencies not installed or Husky not initialized.
+
+**Fix:**
+```bash
+pnpm install
+pnpm prepare
+```
+
+---
+
+### โ Error: `.husky/pre-commit: Permission denied`
+
+**Cause:** Hook scripts are not executable (common on Linux/macOS).
+
+**Fix:**
+```bash
+chmod +x .husky/pre-commit
+chmod +x .husky/pre-push
+```
+
+---
+
+### โ Error: `cannot run .husky/pre-commit: No such file or directory`
+
+**Cause:** Husky hooks were not generated after install.
+
+**Fix:**
+```bash
+pnpm install
+npx husky install
+```
+
+---
+
+### โ Error: `husky - Pre-commit hook exited with code 1`
+
+**Cause:** ESLint or Prettier found errors in your code.
+
+**Fix:**
+```bash
+# Auto-fix lint errors
+pnpm run lint -- --fix
+
+# Auto-fix formatting
+pnpm run format
+
+# Then try committing again
+git add .
+git commit -m "your message"
+```
+
+---
+
+### โ Error: `Type error: ...` on pre-push
+
+**Cause:** TypeScript type check failed before push.
+
+**Fix:**
+```bash
+pnpm run type-check
+```
+
+Fix all type errors shown, then push again.
+
+---
+
+## Pre-commit Hook Failures
+
+Pre-commit runs **ESLint + Prettier** on staged files.
+
+### Step-by-step fix:
+
+```bash
+# 1. Check what errors exist
+pnpm run lint
+
+# 2. Auto-fix what's possible
+pnpm run lint -- --fix
+
+# 3. Check formatting
+pnpm run format
+
+# 4. Stage fixes
+git add .
+
+# 5. Commit again
+git commit -m "fix: resolve lint errors"
+```
+
+---
+
+## Pre-push Hook Failures
+
+Pre-push runs **TypeScript type checking**.
+
+### Step-by-step fix:
+
+```bash
+# 1. Run type check locally
+pnpm run type-check
+
+# 2. Fix all errors shown in terminal
+
+# 3. Push again
+git push origin your-branch
+```
+
+---
+
+## Husky Not Running At All
+
+If Husky hooks are completely silent (no output on commit):
+
+```bash
+# Reinstall husky
+pnpm install
+
+# Reinitialize hooks
+npx husky install
+
+# Verify hooks exist
+ls .husky/
+```
+
+You should see `pre-commit` and `pre-push` files.
+
+---
+
+## Windows-Specific Issues
+
+### โ Error: `pnpm: command not found` in Git Bash
+
+**Fix:** Use PowerShell or CMD instead of Git Bash for pnpm commands.
+
+---
+
+### โ Error: `\r: command not found` (line ending issue)
+
+**Cause:** Windows CRLF line endings in hook files.
+
+**Fix:**
+```bash
+git config --global core.autocrlf false
+```
+
+Then reinstall:
+```bash
+pnpm install
+npx husky install
+```
+
+---
+
+### โ Husky hooks not running in VS Code terminal
+
+**Fix:** Restart VS Code after running `pnpm install`.
+
+---
+
+## Nuclear Reset
+
+If nothing works, do a complete reset:
+
+```bash
+# 1. Remove node_modules and reinstall
+rm -rf node_modules
+pnpm install
+
+# 2. Reinitialize Husky
+npx husky install
+
+# 3. Make hooks executable (Linux/macOS)
+chmod +x .husky/*
+
+# 4. Test with a commit
+git add .
+git commit -m "test: verify husky working"
+```
+
+---
+
+## Quick Reference
+
+| Problem | Command |
+|---|---|
+| Husky not found | `pnpm install && pnpm prepare` |
+| Permission denied | `chmod +x .husky/pre-commit` |
+| Lint errors | `pnpm run lint -- --fix` |
+| Format errors | `pnpm run format` |
+| Type errors | `pnpm run type-check` |
+| Windows line endings | `git config --global core.autocrlf false` |
+| Full reset | `rm -rf node_modules && pnpm install && npx husky install` |
+
+---
+
+## Still Stuck?
+
+- Check [CONTRIBUTING.md](../CONTRIBUTING.md) for setup guide
+- Open a [GitHub Discussion](https://github.com/Priyanshu-byte-coder/devtrack/discussions)
+- Ask in the GSSoC Discord community
+
+---
+
+*This document is maintained for GSSoC 2026 contributors.*
\ No newline at end of file
From be7407729953d5efb2189e31696a0b81473fa596 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:08:29 +0530
Subject: [PATCH 04/41] fix(api): clamp days query param in hourly
contributions route (#2235)
---
.../api/metrics/contributions/hourly/route.ts | 4 +-
test/contributions-hourly.test.ts | 81 +++++++++++++++++++
2 files changed, 84 insertions(+), 1 deletion(-)
create mode 100644 test/contributions-hourly.test.ts
diff --git a/src/app/api/metrics/contributions/hourly/route.ts b/src/app/api/metrics/contributions/hourly/route.ts
index f4c75abc1..6c8740c62 100644
--- a/src/app/api/metrics/contributions/hourly/route.ts
+++ b/src/app/api/metrics/contributions/hourly/route.ts
@@ -17,7 +17,9 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
- const days = Number(req.nextUrl.searchParams.get("days")) || 30;
+ const daysParam = req.nextUrl.searchParams.get("days");
+ const parsedDays = daysParam ? parseInt(daysParam, 10) : NaN;
+ const days = isNaN(parsedDays) ? 30 : Math.max(1, Math.min(365, parsedDays));
const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(
session.githubId ?? session.githubLogin,
diff --git a/test/contributions-hourly.test.ts b/test/contributions-hourly.test.ts
new file mode 100644
index 000000000..91803e66a
--- /dev/null
+++ b/test/contributions-hourly.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+import { GET } from "@/app/api/metrics/contributions/hourly/route";
+
+const mocks = vi.hoisted(() => ({
+ getServerSession: vi.fn(),
+ isMetricsCacheBypassed: vi.fn(() => false),
+ metricsCacheKey: vi.fn(() => "test-cache-key"),
+ withMetricsCache: vi.fn(),
+ fetch: vi.fn(),
+}));
+
+vi.mock("next-auth", () => ({ getServerSession: mocks.getServerSession }));
+vi.mock("@/lib/auth", () => ({ authOptions: {} }));
+vi.mock("@/lib/metrics-cache", () => ({
+ isMetricsCacheBypassed: mocks.isMetricsCacheBypassed,
+ METRICS_CACHE_TTL_SECONDS: { contributions: 3600 },
+ metricsCacheKey: mocks.metricsCacheKey,
+ withMetricsCache: mocks.withMetricsCache,
+}));
+
+vi.stubGlobal("fetch", mocks.fetch);
+
+function makeRequest(days?: string): NextRequest {
+ const url =
+ days === undefined
+ ? "http://localhost/api/metrics/contributions/hourly"
+ : `http://localhost/api/metrics/contributions/hourly?days=${encodeURIComponent(days)}`;
+ return new NextRequest(url);
+}
+
+function authedSession() {
+ mocks.getServerSession.mockResolvedValue({
+ accessToken: "gh-token",
+ githubLogin: "alice",
+ githubId: "12345",
+ });
+}
+
+describe("GET /api/metrics/contributions/hourly โ days validation", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ authedSession();
+ mocks.withMetricsCache.mockImplementation(async (_opts, fn) => fn());
+ mocks.fetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ items: [] }),
+ });
+ });
+
+ it.each([
+ ["-30", 1],
+ ["1.5", 1],
+ ["0", 1],
+ ["Infinity", 30],
+ ["999999", 365],
+ ])("clamps days=%s to %i", async (daysParam, expectedDays) => {
+ const res = await GET(makeRequest(daysParam));
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ days: expectedDays });
+ expect(mocks.fetch).toHaveBeenCalled();
+ });
+
+ it("defaults to 30 days when the parameter is missing", async () => {
+ const res = await GET(makeRequest());
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ days: 30 });
+ });
+
+ it("uses a valid author-date in the GitHub search URL for unbounded input", async () => {
+ const res = await GET(makeRequest("Infinity"));
+
+ expect(res.status).toBe(200);
+ const fetchUrl = String(mocks.fetch.mock.calls[0]?.[0] ?? "");
+ const match = fetchUrl.match(/author-date:>=(\d{4}-\d{2}-\d{2})/);
+ expect(match).not.toBeNull();
+ expect(new Date(match![1]).toString()).not.toBe("Invalid Date");
+ });
+});
From eb01f312b4ae28df41a3b1b3ceaea40c863f5538 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:08:40 +0530
Subject: [PATCH 05/41] fix(api): handle db failure in metrics/repos route with
500 response (#2241)
---
src/app/api/metrics/repos/route.ts | 28 ++++-----
test/repos-api-db-failure.test.ts | 98 ++++++++++++++++++++++++++++++
2 files changed, 112 insertions(+), 14 deletions(-)
create mode 100644 test/repos-api-db-failure.test.ts
diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts
index faf3963ea..51e3da3fe 100644
--- a/src/app/api/metrics/repos/route.ts
+++ b/src/app/api/metrics/repos/route.ts
@@ -309,24 +309,24 @@ export async function GET(req: NextRequest) {
}
// accountId is a different linked account โ look up its token from Supabase.
- const accountToken = await getAccountToken(userRow.id, accountId);
+ try {
+ const accountToken = await getAccountToken(userRow.id, accountId);
- if (!accountToken) {
- return Response.json({ error: "Account not found" }, { status: 404 });
- }
+ if (!accountToken) {
+ return Response.json({ error: "Account not found" }, { status: 404 });
+ }
- const { data: accountRow } = await supabaseAdmin
- .from("user_github_accounts")
- .select("github_login")
- .eq("user_id", userRow.id)
- .eq("github_id", accountId)
- .single();
+ const { data: accountRow } = await supabaseAdmin
+ .from("user_github_accounts")
+ .select("github_login")
+ .eq("user_id", userRow.id)
+ .eq("github_id", accountId)
+ .single();
- if (!accountRow?.github_login) {
- return Response.json({ error: "Account not found" }, { status: 404 });
- }
+ if (!accountRow?.github_login) {
+ return Response.json({ error: "Account not found" }, { status: 404 });
+ }
- try {
const result = await fetchReposForAccount(
accountToken,
accountRow.github_login,
diff --git a/test/repos-api-db-failure.test.ts b/test/repos-api-db-failure.test.ts
new file mode 100644
index 000000000..a54b5eeca
--- /dev/null
+++ b/test/repos-api-db-failure.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { GET } from "@/app/api/metrics/repos/route";
+import { NextRequest } from "next/server";
+import { getServerSession } from "next-auth";
+import { resolveAppUser } from "@/lib/resolve-user";
+import { getAccountToken } from "@/lib/github-accounts";
+import { supabaseAdmin } from "@/lib/supabase";
+
+// Mock next-auth
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+
+// Mock resolve-user
+vi.mock("@/lib/resolve-user", () => ({
+ resolveAppUser: vi.fn(),
+}));
+
+// Mock github-accounts
+vi.mock("@/lib/github-accounts", () => ({
+ getAccountToken: vi.fn(),
+ getAllAccounts: vi.fn(),
+ mergeMetrics: vi.fn(),
+}));
+
+// Mock Supabase admin client
+vi.mock("@/lib/supabase", () => ({
+ supabaseAdmin: {
+ from: vi.fn(),
+ },
+}));
+
+describe("Repos Metrics API Endpoint - DB Failure", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns 502 when supabaseAdmin query throws error during linked account lookup", async () => {
+ // 1. Mock valid authenticated session
+ (getServerSession as any).mockResolvedValue({
+ accessToken: "primary-token",
+ githubId: "primary-id",
+ githubLogin: "primary-login",
+ });
+
+ // 2. Mock resolved app user in resolveAppUser
+ (resolveAppUser as any).mockResolvedValue({
+ id: "user-uuid-123",
+ github_id: "primary-id",
+ github_login: "primary-login",
+ });
+
+ // 3. Mock getAccountToken to return a token (so it passes the first check)
+ (getAccountToken as any).mockResolvedValue("linked-account-token");
+
+ // 4. Mock supabaseAdmin.from to throw an error (simulating database failure or missing client)
+ (supabaseAdmin.from as any).mockImplementation(() => {
+ throw new Error("Supabase admin client is unavailable.");
+ });
+
+ // 5. Build request calling the API with a linked accountId
+ const req = new NextRequest("http://localhost/api/metrics/repos?accountId=linked-id");
+ const res = await GET(req);
+
+ // 6. Verify it returns 502 instead of throwing an unhandled exception (which would result in a 500)
+ expect(res.status).toBe(502);
+ expect(await res.json()).toEqual({ error: "GitHub API error" });
+ });
+
+ it("returns 502 when getAccountToken throws error during linked account lookup", async () => {
+ // 1. Mock valid authenticated session
+ (getServerSession as any).mockResolvedValue({
+ accessToken: "primary-token",
+ githubId: "primary-id",
+ githubLogin: "primary-login",
+ });
+
+ // 2. Mock resolved app user in resolveAppUser
+ (resolveAppUser as any).mockResolvedValue({
+ id: "user-uuid-123",
+ github_id: "primary-id",
+ github_login: "primary-login",
+ });
+
+ // 3. Mock getAccountToken to throw an error
+ (getAccountToken as any).mockImplementation(() => {
+ throw new Error("Supabase admin client is unavailable.");
+ });
+
+ // 5. Build request calling the API with a linked accountId
+ const req = new NextRequest("http://localhost/api/metrics/repos?accountId=linked-id");
+ const res = await GET(req);
+
+ // 6. Verify it returns 502 instead of throwing an unhandled exception (which would result in a 500)
+ expect(res.status).toBe(502);
+ expect(await res.json()).toEqual({ error: "GitHub API error" });
+ });
+});
From c7b2ca761c8a60fbb5b8322d6f71d7cd0790bd5a Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:08:58 +0530
Subject: [PATCH 06/41] security: add X-DNS-Prefetch-Control header and
refactor e2e test helpers (#2236)
---
.github/workflows/e2e.yml | 1 +
e2e/api.spec.ts | 82 ++-------
e2e/dashboard-widgets.spec.js | 31 +++-
e2e/dashboard.spec.ts | 54 ++++--
e2e/goals.spec.ts | 101 ++++-------
e2e/helpers/dashboard-mocks.js | 307 +++++++++++++++++++++++++++++++++
e2e/notifications.spec.js | 30 +++-
e2e/settings.spec.js | 18 +-
e2e/streak.spec.ts | 156 +++++------------
e2e/theme.spec.js | 39 +++--
next.config.mjs | 2 +-
playwright.config.mjs | 2 +
src/middleware.ts | 8 +-
13 files changed, 532 insertions(+), 299 deletions(-)
create mode 100644 e2e/helpers/dashboard-mocks.js
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index f57efc517..f6989bab8 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -48,6 +48,7 @@ jobs:
NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-anon-key
SUPABASE_SERVICE_ROLE_KEY=placeholder-service-role-key
PLAYWRIGHT_SERVER_MODE=start
+ PLAYWRIGHT_TEST=true
EOF
npm run build
diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts
index e8c2d9aad..3607d4317 100644
--- a/e2e/api.spec.ts
+++ b/e2e/api.spec.ts
@@ -51,49 +51,20 @@ test("[API E2E] /api/metrics/streak returns 401 without a session", async ({
expect([401, 302, 403]).toContain(res.status());
});
-test("[API E2E] /api/metrics/contributions returns 200 with valid session cookie", async ({
- page,
+test("[API E2E] /api/metrics/contributions accepts valid session cookie", async ({
request,
}) => {
const sessionToken = await buildSessionCookie();
- // Add the signed cookie to the browser context.
- await page.context().addCookies([
- {
- name: "next-auth.session-token",
- value: sessionToken,
- domain: "127.0.0.1",
- path: "/",
- httpOnly: true,
- sameSite: "Lax",
- secure: false,
- expires: Math.floor(Date.now() / 1000) + 60 * 60,
+ const res = await request.get("/api/metrics/contributions?days=7", {
+ headers: {
+ Cookie: `next-auth.session-token=${sessionToken}`,
},
- ]);
-
- // Mock the NextAuth session verify call so the API handler resolves the user.
- await page.route("**/api/auth/session**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({
- user: { name: "Playwright User", email: "playwright@devtrack.test" },
- githubLogin: "playwright-user",
- githubId: "99001",
- accessToken: "mock-access-token",
- expires: "2099-01-01T00:00:00.000Z",
- }),
- })
- );
-
- // Use the same browser context's fetch so the cookie is sent.
- const res = await page.evaluate(async () => {
- const r = await fetch("/api/metrics/contributions?days=7");
- return { status: r.status, ok: r.ok };
});
- // With a valid session the route must respond 200.
- expect(res.status).toBe(200);
- expect(res.ok).toBe(true);
+ // Session must be accepted; upstream GitHub may return 502 with the mock token.
+ expect(res.status()).not.toBe(401);
+ expect(res.headers()["content-type"] ?? "").toContain("application/json");
});
test("[API E2E] /api/auth/session returns a JSON object", async ({
@@ -116,42 +87,17 @@ test("[API E2E] /api/goals POST without session returns 401 or 403", async ({
});
test("[API E2E] /api/metrics/contributions with days param returns valid JSON when authenticated", async ({
- page,
+ request,
}) => {
const sessionToken = await buildSessionCookie();
- await page.context().addCookies([
- {
- name: "next-auth.session-token",
- value: sessionToken,
- domain: "127.0.0.1",
- path: "/",
- httpOnly: true,
- sameSite: "Lax",
- secure: false,
- expires: Math.floor(Date.now() / 1000) + 60 * 60,
+ const res = await request.get("/api/metrics/contributions?days=30", {
+ headers: {
+ Cookie: `next-auth.session-token=${sessionToken}`,
},
- ]);
-
- await page.route("**/api/auth/session**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({
- user: { name: "Playwright User", email: "playwright@devtrack.test" },
- githubLogin: "playwright-user",
- githubId: "99001",
- accessToken: "mock-access-token",
- expires: "2099-01-01T00:00:00.000Z",
- }),
- })
- );
-
- const result = await page.evaluate(async () => {
- const r = await fetch("/api/metrics/contributions?days=30");
- const body = await r.json();
- return { status: r.status, bodyType: typeof body };
});
- expect(result.status).toBe(200);
- expect(result.bodyType).toBe("object");
+ expect(res.status()).not.toBe(401);
+ const body = await res.json();
+ expect(typeof body).toBe("object");
});
\ No newline at end of file
diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js
index 8612d2e36..35d234ba0 100644
--- a/e2e/dashboard-widgets.spec.js
+++ b/e2e/dashboard-widgets.spec.js
@@ -179,6 +179,27 @@ test.beforeEach(async ({ page }) => {
body: "data: {}\n\n",
});
});
+
+ await page.route("**/api/user/github-orgs**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ orgs: [], hasReadOrgScope: true }),
+ });
+ });
+
+ await page.route("**/api/daily-focus**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ goal: "" }),
+ });
+ });
+
+ await page.route("**/api/user/dashboard-layout**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ layout: null, source: "default" }),
+ });
+ });
});
test("dashboard widgets render with mocked metrics", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "load" });
@@ -294,6 +315,7 @@ function mockMetricResponse(url) {
longest: 9,
lastCommitDate: "2026-05-18",
totalActiveDays: 12,
+ freezeDates: [],
};
}
if (url.includes("/api/metrics/weekly-summary")) {
@@ -329,7 +351,14 @@ function mockMetricResponse(url) {
};
}
if (url.includes("/api/streak/freeze")) {
- return { freezes: [] };
+ return { hasFreeze: false, freezeDate: null };
+ }
+ if (url.includes("/api/metrics/contributions")) {
+ return {
+ days: 365,
+ total: 10,
+ data: { "2026-05-16": 3, "2026-05-17": 5, "2026-05-18": 2 },
+ };
}
if (url.includes("/api/integrations/jira")) {
return null;
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts
index 99471da56..4ef7ddbe4 100644
--- a/e2e/dashboard.spec.ts
+++ b/e2e/dashboard.spec.ts
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
+import { scrollToWidget } from "./helpers/dashboard-mocks";
/**
* dashboard.spec.ts
@@ -141,7 +142,7 @@ async function injectMockSession(page: import("@playwright/test").Page) {
await page.route("**/api/streak/freeze**", (route) =>
route.fulfill({
contentType: "application/json",
- body: JSON.stringify({ freezes: [] }),
+ body: JSON.stringify({ hasFreeze: false, freezeDate: null }),
})
);
@@ -241,6 +242,28 @@ async function injectMockSession(page: import("@playwright/test").Page) {
})
);
+ // โโ Supabase-dependent routes (placeholder env disables admin client) โโโโ
+ await page.route("**/api/user/github-orgs**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ orgs: [], hasReadOrgScope: true }),
+ })
+ );
+
+ await page.route("**/api/daily-focus**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ goal: "" }),
+ })
+ );
+
+ await page.route("**/api/user/dashboard-layout**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ layout: null, source: "default" }),
+ })
+ );
+
// โโ Remaining metric routes (stub to empty) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const stubRoutes = [
"**/api/metrics/repos**",
@@ -287,35 +310,29 @@ test("[Dashboard E2E] dashboard heading is visible after mock login", async ({
});
test("[Dashboard E2E] Commits widget renders", async ({ page }) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- await expect(
- page.getByRole("heading", { name: "Your Commits" })
- ).toBeVisible({ timeout: 10_000 });
+ await scrollToWidget(page, "Your Commits");
});
test("[Dashboard E2E] PR Analytics widget renders", async ({ page }) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- await expect(
- page.getByRole("heading", { name: "PR Analytics" })
- ).toBeVisible({ timeout: 10_000 });
+ await scrollToWidget(page, "PR Analytics");
});
test("[Dashboard E2E] Goals widget renders with mocked goal", async ({
page,
}) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- await expect(
- page.getByRole("heading", { name: "Goals", exact: true })
- ).toBeVisible({ timeout: 10_000 });
+ await scrollToWidget(page, "Goals");
await expect(page.getByText("Make 10 commits")).toBeVisible({
timeout: 10_000,
});
@@ -339,18 +356,17 @@ test("[Dashboard E2E] no uncaught console errors on dashboard load", async ({
(e) =>
!e.includes("favicon") &&
!e.includes("net::ERR_") &&
- !e.includes("ERR_INTERNET_DISCONNECTED")
+ !e.includes("ERR_INTERNET_DISCONNECTED") &&
+ !e.includes("Content Security Policy") &&
+ !e.includes("vercel-scripts.com")
);
expect(appErrors).toHaveLength(0);
});
test("[Dashboard E2E] weekly summary widget renders", async ({ page }) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- // Weekly summary section should appear somewhere on the dashboard.
- await expect(
- page.getByRole("heading", { name: /weekly/i }).first()
- ).toBeVisible({ timeout: 10_000 });
+ await scrollToWidget(page, /weekly summary/i);
});
\ No newline at end of file
diff --git a/e2e/goals.spec.ts b/e2e/goals.spec.ts
index 306f5f150..e329967d4 100644
--- a/e2e/goals.spec.ts
+++ b/e2e/goals.spec.ts
@@ -1,5 +1,9 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
+import {
+ installDashboardApiMocks,
+ scrollToWidget,
+} from "./helpers/dashboard-mocks";
/**
* goals.spec.ts
@@ -79,43 +83,15 @@ async function setupGoalsMocks(page: import("@playwright/test").Page) {
})
);
- // Stub remaining metric routes so the page loads without errors.
- const stubs = [
- "**/api/metrics/contributions**",
- "**/api/metrics/streak**",
- "**/api/streak/freeze**",
- "**/api/metrics/prs**",
- "**/api/metrics/pr-breakdown**",
- "**/api/metrics/pr-review-trend**",
- "**/api/metrics/issues**",
- "**/api/metrics/languages**",
- "**/api/metrics/weekly-summary**",
- "**/api/ai-insights**",
- "**/api/metrics/repos**",
- "**/api/metrics/pinned-repos**",
- "**/api/metrics/compare**",
- "**/api/metrics/repo-health**",
- "**/api/metrics/ci**",
- "**/api/user/github-accounts**",
- "**/api/integrations/jira**",
- "**/api/metrics/activity**",
- "**/api/metrics/commit-time**",
- "**/api/metrics/personal-records**",
- "**/api/metrics/discussions**",
- "**/api/metrics/inactive-repos**",
- "**/api/local-coding/stats**",
- "**/api/metrics/coding-time**",
- "**/api/metrics/coding-activity-insights**",
- "**/api/wakatime**",
- "**/api/metrics/productive-hours**",
- "**/api/user/pinned-repos/details**",
- "**/api/metrics/repo-explorer**",
- ];
- for (const pattern of stubs) {
- await page.route(pattern, (route) =>
- route.fulfill({ contentType: "application/json", body: JSON.stringify({}) })
- );
- }
+ await installDashboardApiMocks(page);
+}
+
+async function openGoalsWidget(page: import("@playwright/test").Page) {
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
+ await expect(
+ page.getByRole("heading", { name: "Dashboard", exact: true })
+ ).toBeVisible({ timeout: 30_000 });
+ await scrollToWidget(page, "Goals");
}
test("[Goals E2E] goals widget renders on dashboard", async ({ page }) => {
@@ -131,13 +107,7 @@ test("[Goals E2E] goals widget renders on dashboard", async ({ page }) => {
});
});
- await page.goto("/dashboard", { waitUntil: "load" });
- await expect(
- page.getByRole("heading", { name: "Dashboard", exact: true })
- ).toBeVisible({ timeout: 30_000 });
- await expect(
- page.getByRole("heading", { name: "Goals", exact: true })
- ).toBeVisible({ timeout: 10_000 });
+ await openGoalsWidget(page);
});
test("[Goals E2E] creating a goal sends POST /api/goals with correct payload", async ({
@@ -162,14 +132,11 @@ test("[Goals E2E] creating a goal sends POST /api/goals with correct payload", a
});
});
- await page.goto("/dashboard", { waitUntil: "load" });
- await expect(
- page.getByRole("heading", { name: "Dashboard", exact: true })
- ).toBeVisible({ timeout: 30_000 });
+ await openGoalsWidget(page);
- await page.getByLabel("Goal title").fill("Ship one PR");
- await page.getByLabel("Target").fill("1");
- await page.getByLabel("Unit").selectOption("prs");
+ await page.locator("#goal-title").fill("Ship one PR");
+ await page.locator("#goal-target").fill("1");
+ await page.locator("#goal-unit").selectOption("prs");
await page.getByRole("button", { name: "Create goal" }).click();
await expect.poll(() => goalPosts, { timeout: 10_000 }).toHaveLength(1);
@@ -219,18 +186,13 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({
});
});
- await page.goto("/dashboard", { waitUntil: "load" });
- await expect(
- page.getByRole("heading", { name: "Dashboard", exact: true })
- ).toBeVisible({ timeout: 30_000 });
+ await openGoalsWidget(page);
- // Existing goal should be present.
await expect(page.getByText("Existing Goal")).toBeVisible({ timeout: 10_000 });
- // Create a new goal.
- await page.getByLabel("Goal title").fill("Ship five PRs");
- await page.getByLabel("Target").fill("5");
- await page.getByLabel("Unit").selectOption("prs");
+ await page.locator("#goal-title").fill("Ship five PRs");
+ await page.locator("#goal-target").fill("5");
+ await page.locator("#goal-unit").selectOption("prs");
await page.getByRole("button", { name: "Create goal" }).click();
// The new goal should appear without a page reload.
@@ -279,18 +241,15 @@ test("[Goals E2E] deleting a goal removes it from the list", async ({
return route.continue();
});
- await page.goto("/dashboard", { waitUntil: "load" });
- await expect(
- page.getByRole("heading", { name: "Dashboard", exact: true })
- ).toBeVisible({ timeout: 30_000 });
+ await openGoalsWidget(page);
await expect(page.getByText("Goal to Delete")).toBeVisible({ timeout: 10_000 });
- // Click the delete button next to this goal.
- const goalRow = page.locator("li, [data-testid='goal-item']").filter({
- hasText: "Goal to Delete",
- });
- await goalRow.getByRole("button", { name: /delete|remove/i }).click();
+ await page
+ .getByRole("button", { name: "Delete goal: Goal to Delete" })
+ .click();
+ await page.getByRole("button", { name: "Permanently Delete" }).click();
- // Goal should be gone.
- await expect(page.getByText("Goal to Delete")).not.toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText("Goal to Delete")).not.toBeVisible({
+ timeout: 10_000,
+ });
});
\ No newline at end of file
diff --git a/e2e/helpers/dashboard-mocks.js b/e2e/helpers/dashboard-mocks.js
new file mode 100644
index 000000000..5986e2952
--- /dev/null
+++ b/e2e/helpers/dashboard-mocks.js
@@ -0,0 +1,307 @@
+import { expect } from "@playwright/test";
+
+/**
+ * Shared Playwright route mocks for authenticated dashboard E2E tests.
+ * Intercepts browser requests so CI placeholder Supabase env and production
+ * middleware rate limits do not break widget rendering.
+ */
+
+export const DEFAULT_STREAK = {
+ current: 12,
+ longest: 21,
+ lastCommitDate: "2026-05-18",
+ totalActiveDays: 63,
+ freezeDates: [],
+};
+
+export const DEFAULT_CONTRIBUTIONS = {
+ days: 365,
+ total: 10,
+ data: {
+ "2026-05-16": 3,
+ "2026-05-17": 5,
+ "2026-05-18": 2,
+ },
+};
+
+export const DEFAULT_FREEZE = {
+ hasFreeze: false,
+ freezeDate: null,
+};
+
+export function mockMetricResponse(url) {
+ if (url.includes("/api/metrics/prs")) {
+ return {
+ open: 3,
+ merged: 9,
+ closed: 1,
+ avgReviewHours: 5,
+ avgFirstReviewHours: 2,
+ mergeRate: "75%",
+ };
+ }
+ if (url.includes("/api/metrics/pr-breakdown")) {
+ return { draft: 1, merged: 9, open: 3, closed: 1 };
+ }
+ if (url.includes("/api/metrics/issues")) {
+ return {
+ opened: 5,
+ closed: 4,
+ currentlyOpen: 1,
+ avgCloseTimeDays: 3,
+ trend: 1,
+ mostActiveRepo: "demo/devtrack",
+ };
+ }
+ if (
+ url.includes("/api/metrics/repos") ||
+ url.includes("/api/metrics/pinned-repos")
+ ) {
+ return {
+ repos: [
+ { name: "demo/repo", commits: 12, url: "https://github.com/demo/repo" },
+ ],
+ };
+ }
+ if (url.includes("/api/metrics/languages")) {
+ return { languages: [{ language: "TypeScript", count: 20 }] };
+ }
+ if (url.includes("/api/metrics/streak")) {
+ return DEFAULT_STREAK;
+ }
+ if (url.includes("/api/metrics/weekly-summary")) {
+ return {
+ commits: { current: 12, previous: 8, delta: 4, trend: "up" },
+ prs: {
+ thisWeek: { opened: 3, merged: 2 },
+ lastWeek: { opened: 1, merged: 1 },
+ },
+ issues: { thisWeek: 5, lastWeek: 3 },
+ productivityScore: { current: 88, previous: 75 },
+ activeDays: { thisWeek: 5, lastWeek: 4 },
+ streak: 7,
+ topRepo: "demo/devtrack",
+ };
+ }
+ if (url.includes("/api/metrics/compare")) {
+ return { user: { commits: 10 }, friend: { commits: 8 } };
+ }
+ if (url.includes("/api/metrics/repo-health")) return { repositories: [] };
+ if (url.includes("/api/metrics/ci")) {
+ return {
+ successRate: 95,
+ averageDurationMinutes: 3,
+ flakiestWorkflow: null,
+ totalRuns: 42,
+ reposChecked: 5,
+ };
+ }
+ if (url.includes("/api/metrics/activity")) return { data: [] };
+ if (url.includes("/api/metrics/commit-time")) return { data: [] };
+ if (url.includes("/api/metrics/personal-records")) return { records: [] };
+ if (url.includes("/api/metrics/discussions")) {
+ return { total: 0, answered: 0 };
+ }
+ if (url.includes("/api/metrics/pr-review-trend")) return { trend: [] };
+ if (url.includes("/api/metrics/inactive-repos")) return { repos: [] };
+ if (url.includes("/api/metrics/coding-time") || url.includes("/api/wakatime")) {
+ return {
+ hasData: false,
+ not_configured: true,
+ todaysSeconds: 0,
+ totalSeconds7Days: 0,
+ chartData: [],
+ topLanguage: "",
+ topProject: "",
+ };
+ }
+ if (url.includes("/api/metrics/coding-activity-insights")) {
+ return {
+ hourlyCounts: [],
+ mostActiveHour: { hour: 0, count: 0, label: "" },
+ leastActiveHour: { hour: 0, count: 0, label: "" },
+ totalActivities: 0,
+ averageDailyCommits: 0,
+ consistencyScore: 0,
+ productivityLevel: "Low",
+ timezone: "UTC",
+ };
+ }
+ if (url.includes("/api/metrics/contributions")) {
+ return DEFAULT_CONTRIBUTIONS;
+ }
+ if (url.includes("/api/metrics/productive-hours")) {
+ return { grid: [], peak: null, total: 0, days: 0, timezone: "UTC" };
+ }
+ if (url.includes("/api/user/pinned-repos/details")) {
+ return { pinnedRepos: [] };
+ }
+ if (url.includes("/api/metrics/repo-explorer")) return { repos: [] };
+ return {};
+}
+
+/**
+ * @param {import('@playwright/test').Page} page
+ * @param {{
+ * streak?: Record;
+ * contributions?: Record;
+ * freeze?: Record;
+ * }} [options]
+ */
+export async function installDashboardApiMocks(page, options = {}) {
+ const streak = options.streak ?? DEFAULT_STREAK;
+ const contributions = options.contributions ?? DEFAULT_CONTRIBUTIONS;
+ const freeze = options.freeze ?? DEFAULT_FREEZE;
+
+ await page.route("**/api/notifications**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ notifications: [], unreadCount: 0 }),
+ })
+ );
+
+ await page.route("**/api/stream**", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/event-stream",
+ body: "data: {}\n\n",
+ })
+ );
+
+ const now = new Date().toISOString();
+
+ await page.route("**/api/goals/sync**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ ok: true, last_synced_at: now }),
+ })
+ );
+
+ await page.route("**/api/metrics/contributions**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(contributions),
+ })
+ );
+
+ await page.route("**/api/metrics/streak**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(streak),
+ })
+ );
+
+ await page.route("**/api/streak/freeze**", async (route) => {
+ if (route.request().method() === "POST") {
+ return route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ ...freeze, hasFreeze: true, freezeDate: "2026-05-18" }),
+ });
+ }
+ return route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(freeze),
+ });
+ });
+
+ await page.route("**/api/metrics/weekly-summary**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(mockMetricResponse("/api/metrics/weekly-summary")),
+ })
+ );
+
+ await page.route("**/api/ai-insights**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ insights: [
+ {
+ id: "i-1",
+ type: "productivity",
+ title: "High Consistency",
+ description: "You coded 5 days in a row!",
+ severity: "positive",
+ },
+ ],
+ trend: { direction: "up", percentage: 18 },
+ aiSummary: "Great week! Keep shipping.",
+ generatedAt: now,
+ },
+ }),
+ })
+ );
+
+ await page.route("**/api/user/github-orgs**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ orgs: [], hasReadOrgScope: true }),
+ })
+ );
+
+ await page.route("**/api/daily-focus**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ goal: "" }),
+ })
+ );
+
+ await page.route("**/api/user/dashboard-layout**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ layout: null, source: "default" }),
+ })
+ );
+
+ const stubRoutes = [
+ "**/api/metrics/prs**",
+ "**/api/metrics/pr-breakdown**",
+ "**/api/metrics/pr-review-trend**",
+ "**/api/metrics/issues**",
+ "**/api/metrics/languages**",
+ "**/api/metrics/repos**",
+ "**/api/metrics/pinned-repos**",
+ "**/api/metrics/compare**",
+ "**/api/metrics/repo-health**",
+ "**/api/metrics/ci**",
+ "**/api/user/github-accounts**",
+ "**/api/integrations/jira**",
+ "**/api/metrics/activity**",
+ "**/api/metrics/commit-time**",
+ "**/api/metrics/personal-records**",
+ "**/api/metrics/discussions**",
+ "**/api/metrics/inactive-repos**",
+ "**/api/local-coding/stats**",
+ "**/api/metrics/coding-time**",
+ "**/api/metrics/coding-activity-insights**",
+ "**/api/wakatime**",
+ "**/api/metrics/productive-hours**",
+ "**/api/user/pinned-repos/details**",
+ "**/api/metrics/repo-explorer**",
+ ];
+
+ for (const pattern of stubRoutes) {
+ await page.route(pattern, (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(mockMetricResponse(route.request().url())),
+ })
+ );
+ }
+}
+
+/** Scroll a dashboard widget heading into view before asserting or clicking. */
+export async function scrollToWidget(page, headingName) {
+ const heading = page.getByRole("heading", { name: headingName }).first();
+ await heading.scrollIntoViewIfNeeded();
+ await expect(heading).toBeVisible({ timeout: 15_000 });
+ return heading;
+}
+
+export function streakSection(page) {
+ return page
+ .getByRole("heading", { name: "Commit Streaks" })
+ .first()
+ .locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
+}
diff --git a/e2e/notifications.spec.js b/e2e/notifications.spec.js
index be4320a04..d986a7b2d 100644
--- a/e2e/notifications.spec.js
+++ b/e2e/notifications.spec.js
@@ -43,7 +43,10 @@ function mockMetricResponse(url) {
longest: 9,
lastCommitDate: "2026-05-18",
totalActiveDays: 12,
+ freezeDates: [],
};
+ if (url.includes("/api/streak/freeze"))
+ return { hasFreeze: false, freezeDate: null };
if (url.includes("/api/metrics/weekly-summary"))
return {
commits: { current: 10, previous: 7, delta: 3, trend: "up" },
@@ -97,7 +100,11 @@ function mockMetricResponse(url) {
timezone: "UTC",
};
if (url.includes("/api/metrics/contributions"))
- return { data: { "2026-05-16": 3, "2026-05-17": 5, "2026-05-18": 2 } };
+ return {
+ days: 365,
+ total: 10,
+ data: { "2026-05-16": 3, "2026-05-17": 5, "2026-05-18": 2 },
+ };
if (url.includes("/api/metrics/productive-hours"))
return { grid: [], peak: null, total: 0, days: 0, timezone: "UTC" };
if (url.includes("/api/user/pinned-repos/details"))
@@ -262,6 +269,27 @@ test.beforeEach(async ({ page }) => {
body: "data: {}\n\n",
});
});
+
+ await page.route("**/api/user/github-orgs**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ orgs: [], hasReadOrgScope: true }),
+ });
+ });
+
+ await page.route("**/api/daily-focus**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ goal: "" }),
+ });
+ });
+
+ await page.route("**/api/user/dashboard-layout**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ layout: null, source: "default" }),
+ });
+ });
});
test("notification bell opens and closes drawer", async ({ page }) => {
diff --git a/e2e/settings.spec.js b/e2e/settings.spec.js
index 358ac5533..421764402 100644
--- a/e2e/settings.spec.js
+++ b/e2e/settings.spec.js
@@ -88,19 +88,11 @@ test("settings page saves and reflects changes", async ({ page }) => {
// Wait for settings to load
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible();
- // Find the public profile toggle (which is visually a checkbox)
- // The label wraps the checkbox and "Public Profile" isn't strictly associated with the input
- // Since we know the DOM structure:
- // It has a hidden input type="checkbox"
- const publicProfileCheckbox = page.locator("input[type='checkbox']").first();
-
- // Initially false based on our mock
+ const publicProfileCheckbox = page.getByRole("checkbox", {
+ name: "Toggle Public Profile",
+ });
+
await expect(publicProfileCheckbox).not.toBeChecked();
-
- // Click the label/toggle container to change it
- // We can click the parent container or the label
- await page.locator("text=Public Profile").locator("..").locator("..").locator("input[type='checkbox']").first().evaluate(node => node.click());
-
- // It should now be checked (our mock returns the patched value)
+ await publicProfileCheckbox.check({ force: true });
await expect(publicProfileCheckbox).toBeChecked();
});
diff --git a/e2e/streak.spec.ts b/e2e/streak.spec.ts
index cefabe2e1..cc7697f51 100644
--- a/e2e/streak.spec.ts
+++ b/e2e/streak.spec.ts
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
-
-/**
- * streak.spec.ts
- * Covers: streak widget shows numeric values; freeze button is present.
- */
+import {
+ installDashboardApiMocks,
+ scrollToWidget,
+ streakSection,
+} from "./helpers/dashboard-mocks";
const AUTH_SECRET =
process.env.NEXTAUTH_SECRET ?? "test-nextauth-secret-for-playwright-tests";
@@ -56,49 +56,6 @@ async function setupStreakMocks(page: import("@playwright/test").Page) {
})
);
- await page.route("**/api/notifications**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({ notifications: [], unreadCount: 0 }),
- })
- );
-
- await page.route("**/api/stream**", (route) =>
- route.fulfill({
- status: 200,
- contentType: "text/event-stream",
- body: "data: {}\n\n",
- })
- );
-
- // โโ Streak data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- await page.route("**/api/metrics/streak**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({
- current: 12,
- longest: 21,
- lastCommitDate: "2026-05-18",
- totalActiveDays: 63,
- }),
- })
- );
-
- await page.route("**/api/streak/freeze**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({ freezes: [] }),
- })
- );
-
- // โโ Goals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- await page.route("**/api/goals/sync**", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({ ok: true, last_synced_at: new Date().toISOString() }),
- })
- );
-
await page.route("**/api/goals**", (route) =>
route.fulfill({
contentType: "application/json",
@@ -106,41 +63,7 @@ async function setupStreakMocks(page: import("@playwright/test").Page) {
})
);
- // โโ Stub remaining metrics โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- const stubs = [
- "**/api/metrics/contributions**",
- "**/api/metrics/prs**",
- "**/api/metrics/pr-breakdown**",
- "**/api/metrics/pr-review-trend**",
- "**/api/metrics/issues**",
- "**/api/metrics/languages**",
- "**/api/metrics/weekly-summary**",
- "**/api/ai-insights**",
- "**/api/metrics/repos**",
- "**/api/metrics/pinned-repos**",
- "**/api/metrics/compare**",
- "**/api/metrics/repo-health**",
- "**/api/metrics/ci**",
- "**/api/user/github-accounts**",
- "**/api/integrations/jira**",
- "**/api/metrics/activity**",
- "**/api/metrics/commit-time**",
- "**/api/metrics/personal-records**",
- "**/api/metrics/discussions**",
- "**/api/metrics/inactive-repos**",
- "**/api/local-coding/stats**",
- "**/api/metrics/coding-time**",
- "**/api/metrics/coding-activity-insights**",
- "**/api/wakatime**",
- "**/api/metrics/productive-hours**",
- "**/api/user/pinned-repos/details**",
- "**/api/metrics/repo-explorer**",
- ];
- for (const pattern of stubs) {
- await page.route(pattern, (route) =>
- route.fulfill({ contentType: "application/json", body: JSON.stringify({}) })
- );
- }
+ await installDashboardApiMocks(page);
}
test.beforeEach(async ({ page }) => {
@@ -150,52 +73,63 @@ test.beforeEach(async ({ page }) => {
test("[Streak E2E] streak widget section is rendered on dashboard", async ({
page,
}) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- // The streak section may use "Streak", "Current Streak", or similar heading.
- await expect(
- page.getByRole("heading", { name: /streak/i }).first()
- ).toBeVisible({ timeout: 10_000 });
+
+ await scrollToWidget(page, "Commit Streaks");
});
test("[Streak E2E] streak widget shows the mocked current streak value", async ({
page,
}) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- // The mock returns current: 12 โ this digit must appear in the streak area.
- await expect(page.getByText(/12/).first()).toBeVisible({ timeout: 10_000 });
+ const section = streakSection(page);
+ await section.scrollIntoViewIfNeeded();
+ await expect(section.getByText("Current Streak")).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(section.getByText("12", { exact: true })).toBeVisible({
+ timeout: 10_000,
+ });
});
test("[Streak E2E] streak widget shows the mocked longest streak value", async ({
page,
}) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- // The mock returns longest: 21.
- await expect(page.getByText(/21/).first()).toBeVisible({ timeout: 10_000 });
+ const section = streakSection(page);
+ await section.scrollIntoViewIfNeeded();
+ await expect(section.getByText("Longest Streak")).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(section.getByText("21", { exact: true })).toBeVisible({
+ timeout: 10_000,
+ });
});
test("[Streak E2E] freeze button is present in the streak widget", async ({
page,
}) => {
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- // Freeze / Protect button should be visible in the streak section.
- await expect(
- page.getByRole("button", { name: /freeze|protect/i }).first()
- ).toBeVisible({ timeout: 10_000 });
+ const freezeButton = streakSection(page).getByRole("button", {
+ name: "Freeze Streak",
+ });
+ await freezeButton.scrollIntoViewIfNeeded();
+ await expect(freezeButton).toBeVisible({ timeout: 15_000 });
});
test("[Streak E2E] streak freeze API is called when freeze button is clicked", async ({
@@ -208,29 +142,31 @@ test("[Streak E2E] streak freeze API is called when freeze button is clicked", a
freezeRequests.push(route.request().url());
return route.fulfill({
contentType: "application/json",
- body: JSON.stringify({ ok: true, freezes: [{ date: "2026-05-18" }] }),
+ body: JSON.stringify({
+ hasFreeze: true,
+ freezeDate: "2026-05-18",
+ }),
});
}
- // GET
return route.fulfill({
contentType: "application/json",
- body: JSON.stringify({ freezes: [] }),
+ body: JSON.stringify({ hasFreeze: false, freezeDate: null }),
});
});
- await page.goto("/dashboard", { waitUntil: "load" });
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30_000 });
- const freezeBtn = page
- .getByRole("button", { name: /freeze|protect/i })
- .first();
- await expect(freezeBtn).toBeVisible({ timeout: 10_000 });
- await freezeBtn.click();
+ const freezeButton = streakSection(page).getByRole("button", {
+ name: "Freeze Streak",
+ });
+ await freezeButton.scrollIntoViewIfNeeded();
+ await expect(freezeButton).toBeVisible({ timeout: 15_000 });
+ await freezeButton.click();
- // Give the network request time to fire.
await expect
.poll(() => freezeRequests.length, { timeout: 8_000 })
.toBeGreaterThan(0);
-});
\ No newline at end of file
+});
diff --git a/e2e/theme.spec.js b/e2e/theme.spec.js
index fe7c68866..8f0c253c4 100644
--- a/e2e/theme.spec.js
+++ b/e2e/theme.spec.js
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
+import { installDashboardApiMocks } from "./helpers/dashboard-mocks.js";
const authSecret =
process.env.NEXTAUTH_SECRET ||
@@ -51,27 +52,41 @@ test.beforeEach(async ({ page }) => {
body: JSON.stringify({ is_public: true }),
});
});
+
+ await page.route("**/api/notifications**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ notifications: [], unreadCount: 0 }),
+ });
+ });
+
+ await page.route("**/api/goals**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ goals: [] }),
+ });
+ });
+
+ await installDashboardApiMocks(page);
});
test("theme selector switches between themes on the dashboard", async ({ page }) => {
- await page.goto("/dashboard");
+ await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
+ await expect(
+ page.getByRole("heading", { name: "Dashboard", exact: true })
+ ).toBeVisible({ timeout: 30_000 });
- // The DashboardHeader provides the ThemeToggle select on the dashboard
- const themeSelect = page
- .locator('select[aria-label="Select dashboard theme"]')
- .first();
- await expect(themeSelect).toBeVisible({ timeout: 10000 });
+ const themeSelect = page.getByRole("combobox", {
+ name: "Select dashboard theme",
+ });
+ await expect(themeSelect).toBeVisible({ timeout: 10_000 });
const initialValue = await themeSelect.inputValue();
-
- // Pick a different theme from the available options
- const nextTheme = initialValue === "classic-dark" ? "modern-light-blue" : "classic-dark";
+ const nextTheme =
+ initialValue === "classic-dark" ? "modern-light-blue" : "classic-dark";
await themeSelect.selectOption(nextTheme);
- // Verify the select value updated
await expect(themeSelect).toHaveValue(nextTheme);
-
- // Verify the theme is persisted to localStorage
const stored = await page.evaluate(() => localStorage.getItem("theme"));
expect(stored).toBe(nextTheme);
});
diff --git a/next.config.mjs b/next.config.mjs
index 8e4dc1773..bf1a5e708 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -156,6 +156,7 @@ const nextConfig = {
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
+ { key: "X-DNS-Prefetch-Control", value: "on" },
{
// OWASP recommends a minimum of 2 years (63,072,000 seconds).
// preload submits the domain to the browser HSTS preload lists,
@@ -164,7 +165,6 @@ const nextConfig = {
value: "max-age=63072000; includeSubDomains; preload",
},
{ key: "X-XSS-Protection", value: "1; mode=block" },
- { key: "X-DNS-Prefetch-Control", value: "off" },
{
key: "Content-Security-Policy",
// base-uri 'none' โ blocks tag injection that could hijack
diff --git a/playwright.config.mjs b/playwright.config.mjs
index 68224f364..4b1a8aa7b 100644
--- a/playwright.config.mjs
+++ b/playwright.config.mjs
@@ -12,6 +12,7 @@ export default defineConfig({
timeout: 8_000,
},
fullyParallel: true,
+ workers: process.env.CI ? 1 : undefined,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
@@ -40,6 +41,7 @@ export default defineConfig({
SUPABASE_SERVICE_ROLE_KEY: "placeholder-service-role-key",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
+ PLAYWRIGHT_TEST: "true",
},
},
projects: [
diff --git a/src/middleware.ts b/src/middleware.ts
index 9acd51988..6bf60611f 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -14,6 +14,8 @@ import {
export const runtime = "nodejs";
const isDev = process.env.NODE_ENV === "development";
+const isPlaywrightE2E = process.env.PLAYWRIGHT_TEST === "true";
+const isRelaxedRateLimit = isDev || isPlaywrightE2E;
/**
* Configuration constants for API rate limits and window sizes.
@@ -29,17 +31,17 @@ const RATE_LIMIT_CONFIG = {
/**
* Maximum allowed API metrics requests for authenticated users in the window.
*/
- AUTHENTICATED_LIMIT: isDev ? 5000 : 60,
+ AUTHENTICATED_LIMIT: isRelaxedRateLimit ? 5000 : 60,
/**
* Maximum allowed API metrics requests for anonymous users in the window.
*/
- ANONYMOUS_LIMIT: isDev ? 1000 : 10,
+ ANONYMOUS_LIMIT: isRelaxedRateLimit ? 1000 : 10,
/**
* Maximum allowed sign-in attempts for authentication routes in the window.
*/
- AUTH_LIMIT: isDev ? 1000 : AUTH_LIMIT,
+ AUTH_LIMIT: isRelaxedRateLimit ? 1000 : AUTH_LIMIT,
} as const;
const memoryBuckets = new Map();
From 82fb81cf6a40113d2d756c0935291d9c4eae2c88 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:09:11 +0530
Subject: [PATCH 07/41] fix(security): enable RLS on daily_notes table and use
stable user id for auth (#2216)
---
src/app/api/daily-note/route.ts | 31 ++++++++----------
.../20260608000000_add_daily_notes_rls.sql | 32 +++++++++++++++++++
2 files changed, 46 insertions(+), 17 deletions(-)
create mode 100644 supabase/migrations/20260608000000_add_daily_notes_rls.sql
diff --git a/src/app/api/daily-note/route.ts b/src/app/api/daily-note/route.ts
index 86919d1d1..865b6e02a 100644
--- a/src/app/api/daily-note/route.ts
+++ b/src/app/api/daily-note/route.ts
@@ -1,16 +1,21 @@
import { NextResponse, NextRequest } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
-import { getToken } from "next-auth/jwt";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { resolveAppUser } from "@/lib/resolve-user";
-export async function GET(req: NextRequest) {
- try {
- const token = await getToken({
- req,
- secret: process.env.NEXTAUTH_SECRET,
- });
+export const dynamic = "force-dynamic";
- const userId = token?.githubId;
+async function getAppUserId(req: NextRequest): Promise {
+ const session = await getServerSession(authOptions);
+ if (!session?.githubId) return null;
+ const user = await resolveAppUser(session.githubId, session.githubLogin);
+ return user?.id ?? null;
+}
+export async function GET(req: NextRequest) {
+ try {
+ const userId = await getAppUserId(req);
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -30,7 +35,6 @@ export async function GET(req: NextRequest) {
.single();
if (todayError && todayError.code !== "PGRST116") {
- console.error("Failed to fetch today's daily note:", todayError);
return NextResponse.json(
{ error: "Failed to fetch daily notes" },
{ status: 500 }
@@ -45,7 +49,6 @@ export async function GET(req: NextRequest) {
.single();
if (yesterdayError && yesterdayError.code !== "PGRST116") {
- console.error("Failed to fetch yesterday's daily note:", yesterdayError);
return NextResponse.json(
{ error: "Failed to fetch daily notes" },
{ status: 500 }
@@ -63,13 +66,7 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
- const token = await getToken({
- req,
- secret: process.env.NEXTAUTH_SECRET,
- });
-
- const userId = token?.githubId;
-
+ const userId = await getAppUserId(req);
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
diff --git a/supabase/migrations/20260608000000_add_daily_notes_rls.sql b/supabase/migrations/20260608000000_add_daily_notes_rls.sql
new file mode 100644
index 000000000..d35a54faa
--- /dev/null
+++ b/supabase/migrations/20260608000000_add_daily_notes_rls.sql
@@ -0,0 +1,32 @@
+-- Migration: enable Row Level Security on daily_notes and align user_id to users.id
+--
+-- The daily_notes table was created without RLS in 20260515000002_add_daily_notes.sql.
+-- This migration:
+-- 1. Enables RLS so the table is protected from direct client access by default.
+-- 2. Adds the four standard CRUD policies matching the pattern used by daily_focus.
+--
+-- NOTE: The API route (src/app/api/daily-note/route.ts) has been updated in the same
+-- PR to resolve users.id (a stable UUID) via resolveAppUser() instead of storing the
+-- raw GitHub numeric ID. Existing rows that used the GitHub numeric ID as user_id will
+-- no longer be matched by the updated API, effectively orphaning them. This is
+-- acceptable for daily notes (ephemeral, low-stakes data) and avoids a risky in-place
+-- data migration. A future cleanup migration can delete orphaned rows if needed.
+
+ALTER TABLE daily_notes ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can read own daily_notes"
+ ON daily_notes FOR SELECT
+ USING (auth.uid()::text = user_id);
+
+CREATE POLICY "Users can insert own daily_notes"
+ ON daily_notes FOR INSERT
+ WITH CHECK (auth.uid()::text = user_id);
+
+CREATE POLICY "Users can update own daily_notes"
+ ON daily_notes FOR UPDATE
+ USING (auth.uid()::text = user_id)
+ WITH CHECK (auth.uid()::text = user_id);
+
+CREATE POLICY "Users can delete own daily_notes"
+ ON daily_notes FOR DELETE
+ USING (auth.uid()::text = user_id);
From 6df3a0d07a90002a17d652311574e81df61b25d0 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:09:24 +0530
Subject: [PATCH 08/41] fix(rooms): replace broken Supabase Realtime with
polling for message updates (#2217)
---
src/app/api/rooms/[roomId]/messages/route.ts | 9 ++-
src/app/rooms/[roomId]/RoomClient.tsx | 23 ++++--
src/components/rooms/MessageFeed.tsx | 74 +++++++++++---------
src/lib/supabase-rooms.ts | 11 +++
4 files changed, 76 insertions(+), 41 deletions(-)
diff --git a/src/app/api/rooms/[roomId]/messages/route.ts b/src/app/api/rooms/[roomId]/messages/route.ts
index aa9bf5973..9decc74d6 100644
--- a/src/app/api/rooms/[roomId]/messages/route.ts
+++ b/src/app/api/rooms/[roomId]/messages/route.ts
@@ -1,6 +1,6 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
-import { getRoomById, getRoomMessages, sendRoomMessage } from '@/lib/supabase-rooms';
+import { getRoomById, getRoomMessages, getRoomMessagesSince, sendRoomMessage } from '@/lib/supabase-rooms';
import { validateTextInput } from '@/lib/sanitize';
import { NextResponse } from 'next/server';
@@ -15,6 +15,11 @@ export async function GET(
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const url = new URL(req.url);
const before = url.searchParams.get('before') ?? undefined;
+ const after = url.searchParams.get('after') ?? undefined;
+ if (after) {
+ const messages = await getRoomMessagesSince(params.roomId, after);
+ return NextResponse.json(messages);
+ }
const messages = await getRoomMessages(params.roomId, 50, before);
return NextResponse.json(messages);
}
@@ -39,4 +44,4 @@ export async function POST(
validation.value
);
return NextResponse.json(message, { status: 201 });
-}
\ No newline at end of file
+}
diff --git a/src/app/rooms/[roomId]/RoomClient.tsx b/src/app/rooms/[roomId]/RoomClient.tsx
index 6d00aa88b..a905c5f90 100644
--- a/src/app/rooms/[roomId]/RoomClient.tsx
+++ b/src/app/rooms/[roomId]/RoomClient.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import type { CollaborationRoom, RoomMember, RoomMessage } from '@/types/rooms';
@@ -23,10 +23,24 @@ export default function RoomClient({
const [messages, setMessages] = useState(initialMessages);
const [members, setMembers] = useState(initialMembers);
+ // Optimistic update: immediately show the message the current user just sent.
function handleSent(msg: RoomMessage) {
- setMessages((prev) => [...prev, msg]);
+ setMessages((prev) => {
+ if (prev.some((m) => m.id === msg.id)) return prev;
+ return [...prev, msg];
+ });
}
+ // Called by MessageFeed's polling loop with messages from other participants.
+ // useCallback prevents the effect in MessageFeed from restarting on every render.
+ const handleNewMessages = useCallback((incoming: RoomMessage[]) => {
+ setMessages((prev) => {
+ const existingIds = new Set(prev.map((m) => m.id));
+ const novel = incoming.filter((m) => !existingIds.has(m.id));
+ return novel.length > 0 ? [...prev, ...novel] : prev;
+ });
+ }, []);
+
function handleMemberAdded(username: string) {
setMembers((prev) => [
...prev,
@@ -87,7 +101,8 @@ export default function RoomClient({
@@ -100,4 +115,4 @@ export default function RoomClient({
);
-}
\ No newline at end of file
+}
diff --git a/src/components/rooms/MessageFeed.tsx b/src/components/rooms/MessageFeed.tsx
index a7ec7f6c3..c48952124 100644
--- a/src/components/rooms/MessageFeed.tsx
+++ b/src/components/rooms/MessageFeed.tsx
@@ -1,56 +1,60 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
-import { createClient } from '@supabase/supabase-js';
+import { useEffect, useRef } from 'react';
import type { RoomMessage } from '@/types/rooms';
-// Public (anon) client โ only for Realtime subscription (no RLS bypass)
-const realtimeClient = createClient(
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
-);
+const POLL_INTERVAL_MS = 5_000;
interface Props {
roomId: string;
currentUser: string;
- initialMessages: RoomMessage[];
+ messages: RoomMessage[];
+ onNewMessages: (msgs: RoomMessage[]) => void;
}
-export default function MessageFeed({ roomId, currentUser, initialMessages }: Props) {
- const [messages, setMessages] = useState(initialMessages);
+export default function MessageFeed({ roomId, currentUser, messages, onNewMessages }: Props) {
const bottomRef = useRef(null);
+ const latestTimestampRef = useRef(
+ messages.length > 0 ? messages[messages.length - 1].created_at : null
+ );
+
+ // Keep the latest-timestamp cursor in sync as the message list grows.
+ useEffect(() => {
+ if (messages.length > 0) {
+ latestTimestampRef.current = messages[messages.length - 1].created_at;
+ }
+ }, [messages]);
- // Scroll to bottom when new messages arrive
+ // Scroll to bottom whenever new messages arrive.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
- // Supabase Realtime subscription
+ // Poll the authenticated API route for messages from other participants.
+ // The Supabase anon key carries no JWT, so the RLS policies on room_messages
+ // block all Realtime broadcasts for NextAuth-based sessions. Polling the
+ // server-side authenticated route is the correct approach.
useEffect(() => {
- const channel = realtimeClient
- .channel(`room-messages-${roomId}`)
- .on(
- 'postgres_changes',
- {
- event: 'INSERT',
- schema: 'public',
- table: 'room_messages',
- filter: `room_id=eq.${roomId}`,
- },
- (payload) => {
- setMessages((prev) => {
- // Avoid duplicates (optimistic update already added it)
- if (prev.some((m) => m.id === payload.new.id)) return prev;
- return [...prev, payload.new as RoomMessage];
- });
+ const poll = async () => {
+ const after = latestTimestampRef.current;
+ if (!after) return;
+ try {
+ const res = await fetch(
+ `/api/rooms/${roomId}/messages?after=${encodeURIComponent(after)}`
+ );
+ if (!res.ok) return;
+ const incoming: RoomMessage[] = await res.json();
+ if (incoming.length > 0) {
+ onNewMessages(incoming);
}
- )
- .subscribe();
-
- return () => {
- realtimeClient.removeChannel(channel);
+ } catch {
+ // Network error โ silently retry on the next tick.
+ }
};
- }, [roomId]);
+
+ const id = setInterval(poll, POLL_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, [roomId, onNewMessages]);
return (
@@ -101,4 +105,4 @@ export default function MessageFeed({ roomId, currentUser, initialMessages }: Pr
);
-}
\ No newline at end of file
+}
diff --git a/src/lib/supabase-rooms.ts b/src/lib/supabase-rooms.ts
index 2f729176d..42132e08b 100644
--- a/src/lib/supabase-rooms.ts
+++ b/src/lib/supabase-rooms.ts
@@ -49,6 +49,17 @@ export async function getRoomMessages(roomId: string, limit = 50, before?: strin
return (data ?? []).reverse();
}
+export async function getRoomMessagesSince(roomId: string, after: string): Promise {
+ const { data, error } = await supabaseAdmin
+ .from("room_messages")
+ .select("*")
+ .eq("room_id", roomId)
+ .gt("created_at", after)
+ .order("created_at", { ascending: true });
+ if (error) throw error;
+ return data ?? [];
+}
+
export async function sendRoomMessage(roomId: string, senderUsername: string, senderAvatar: string | null, content: string): Promise {
const { data, error } = await supabaseAdmin.from("room_messages").insert({ room_id: roomId, sender_username: senderUsername, sender_avatar: senderAvatar, content }).select().single();
if (error) throw error;
From 5ece1d0599bce6c728043291b7fb4cb6b48519b0 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:09:59 +0530
Subject: [PATCH 09/41] fix: normalize room github usernames (#2188)
---
src/app/api/rooms/[roomId]/invite/route.ts | 22 ++++++++++++--------
src/app/api/rooms/[roomId]/messages/route.ts | 12 +++++------
src/app/api/rooms/[roomId]/route.ts | 10 ++++-----
src/app/api/rooms/route.ts | 10 ++++-----
src/lib/rooms.ts | 11 ++++++++++
test/rooms.test.ts | 22 ++++++++++++++++++++
6 files changed, 62 insertions(+), 25 deletions(-)
create mode 100644 src/lib/rooms.ts
create mode 100644 test/rooms.test.ts
diff --git a/src/app/api/rooms/[roomId]/invite/route.ts b/src/app/api/rooms/[roomId]/invite/route.ts
index ac838d64e..c6e2bf73d 100644
--- a/src/app/api/rooms/[roomId]/invite/route.ts
+++ b/src/app/api/rooms/[roomId]/invite/route.ts
@@ -1,6 +1,7 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getRoomById, getRoomMembers, addRoomMember } from '@/lib/supabase-rooms';
+import { githubUsernamesEqual, normalizeRoomGithubUsername } from '@/lib/rooms';
import { NextResponse } from 'next/server';
export async function POST(
@@ -8,16 +9,17 @@ export async function POST(
{ params }: { params: { roomId: string } }
) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const room = await getRoomById(params.roomId, session.user.name);
+ const room = await getRoomById(params.roomId, session.githubLogin);
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!room.is_owner)
return NextResponse.json({ error: 'Only the room owner can invite' }, { status: 403 });
const { github_username } = await req.json();
- if (!github_username?.trim())
- return NextResponse.json({ error: 'github_username required' }, { status: 400 });
- const ghRes = await fetch(`https://api.github.com/users/${github_username}`, {
+ const normalizedUsername = normalizeRoomGithubUsername(github_username);
+ if (!normalizedUsername)
+ return NextResponse.json({ error: 'Valid github_username required' }, { status: 400 });
+ const ghRes = await fetch(`https://api.github.com/users/${encodeURIComponent(normalizedUsername)}`, {
headers: {
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN
@@ -26,12 +28,14 @@ export async function POST(
},
});
if (ghRes.status === 404)
- return NextResponse.json({ error: `GitHub user "${github_username}" does not exist` }, { status: 404 });
+ return NextResponse.json({ error: `GitHub user "${normalizedUsername}" does not exist` }, { status: 404 });
if (!ghRes.ok)
return NextResponse.json({ error: 'Could not verify GitHub user' }, { status: 502 });
+ const githubUser = await ghRes.json() as { login?: string };
+ const canonicalUsername = normalizeRoomGithubUsername(githubUser.login) ?? normalizedUsername;
const members = await getRoomMembers(params.roomId);
- if (members.some((m) => m.github_username === github_username))
+ if (members.some((m) => githubUsernamesEqual(m.github_username, canonicalUsername)))
return NextResponse.json({ error: 'User is already a member' }, { status: 409 });
- await addRoomMember(params.roomId, github_username);
+ await addRoomMember(params.roomId, canonicalUsername);
return NextResponse.json({ success: true });
-}
\ No newline at end of file
+}
diff --git a/src/app/api/rooms/[roomId]/messages/route.ts b/src/app/api/rooms/[roomId]/messages/route.ts
index 9decc74d6..9430a648d 100644
--- a/src/app/api/rooms/[roomId]/messages/route.ts
+++ b/src/app/api/rooms/[roomId]/messages/route.ts
@@ -9,9 +9,9 @@ export async function GET(
{ params }: { params: { roomId: string } }
) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const room = await getRoomById(params.roomId, session.user.name);
+ const room = await getRoomById(params.roomId, session.githubLogin);
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const url = new URL(req.url);
const before = url.searchParams.get('before') ?? undefined;
@@ -29,9 +29,9 @@ export async function POST(
{ params }: { params: { roomId: string } }
) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const room = await getRoomById(params.roomId, session.user.name);
+ const room = await getRoomById(params.roomId, session.githubLogin);
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const body = await req.json();
const validation = validateTextInput(body?.content, 'content', 4000);
@@ -39,8 +39,8 @@ export async function POST(
return NextResponse.json({ error: validation.error }, { status: 400 });
const message = await sendRoomMessage(
params.roomId,
- session.user.name,
- session.user.image ?? null,
+ session.githubLogin,
+ session.user?.image ?? null,
validation.value
);
return NextResponse.json(message, { status: 201 });
diff --git a/src/app/api/rooms/[roomId]/route.ts b/src/app/api/rooms/[roomId]/route.ts
index 8000688ed..7f1a720b9 100644
--- a/src/app/api/rooms/[roomId]/route.ts
+++ b/src/app/api/rooms/[roomId]/route.ts
@@ -9,9 +9,9 @@ export async function GET(
{ params }: { params: { roomId: string } }
) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const room = await getRoomById(params.roomId, session.user.name);
+ const room = await getRoomById(params.roomId, session.githubLogin);
if (!room) return NextResponse.json({ error: 'Not found or not a member' }, { status: 404 });
const members = await getRoomMembers(params.roomId);
return NextResponse.json({ ...room, members });
@@ -22,10 +22,10 @@ export async function DELETE(
{ params }: { params: { roomId: string } }
) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const room = await getRoomById(params.roomId, session.user.name);
+ const room = await getRoomById(params.roomId, session.githubLogin);
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!room.is_owner)
return NextResponse.json({ error: 'Only the owner can delete this room' }, { status: 403 });
@@ -37,4 +37,4 @@ export async function DELETE(
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
-}
\ No newline at end of file
+}
diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts
index ab0b328c8..9799bcf78 100644
--- a/src/app/api/rooms/route.ts
+++ b/src/app/api/rooms/route.ts
@@ -6,10 +6,10 @@ import type { CreateRoomPayload } from '@/types/rooms';
export async function GET() {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
- const rooms = await getRoomsForUser(session.user.name);
+ const rooms = await getRoomsForUser(session.githubLogin);
return NextResponse.json(rooms);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
@@ -18,15 +18,15 @@ export async function GET() {
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
- if (!session?.user?.name)
+ if (!session?.githubLogin)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body: CreateRoomPayload = await req.json();
if (!body.name?.trim() || !body.repo_owner?.trim() || !body.repo_name?.trim())
return NextResponse.json({ error: 'name, repo_owner, and repo_name are required' }, { status: 400 });
try {
- const room = await createRoom(body, session.user.name);
+ const room = await createRoom(body, session.githubLogin);
return NextResponse.json(room, { status: 201 });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts
new file mode 100644
index 000000000..161cb0b33
--- /dev/null
+++ b/src/lib/rooms.ts
@@ -0,0 +1,11 @@
+import { normalizeGitHubUsername } from "./validate-github-username";
+
+export function normalizeRoomGithubUsername(
+ value: string | null | undefined
+): string | null {
+ return normalizeGitHubUsername(value);
+}
+
+export function githubUsernamesEqual(a: string, b: string): boolean {
+ return a.toLowerCase() === b.toLowerCase();
+}
diff --git a/test/rooms.test.ts b/test/rooms.test.ts
new file mode 100644
index 000000000..256806c68
--- /dev/null
+++ b/test/rooms.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import {
+ githubUsernamesEqual,
+ normalizeRoomGithubUsername,
+} from "@/lib/rooms";
+
+describe("room username helpers", () => {
+ it("normalizes valid GitHub usernames", () => {
+ expect(normalizeRoomGithubUsername(" Octocat ")).toBe("Octocat");
+ });
+
+ it("rejects invalid GitHub usernames", () => {
+ expect(normalizeRoomGithubUsername("../octocat")).toBeNull();
+ expect(normalizeRoomGithubUsername("-octocat")).toBeNull();
+ expect(normalizeRoomGithubUsername("octocat-")).toBeNull();
+ });
+
+ it("compares GitHub usernames case-insensitively", () => {
+ expect(githubUsernamesEqual("Octocat", "octocat")).toBe(true);
+ expect(githubUsernamesEqual("hubot", "octocat")).toBe(false);
+ });
+});
From 821af0fa47fd820c9b3e862e24f9acd2831a3a5f Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:10:34 +0530
Subject: [PATCH 10/41] fix(rooms): add input length validation and per-user
room ownership cap (#2219)
---
src/app/api/rooms/route.ts | 80 ++++++++++++++++++++----
src/components/rooms/CreateRoomModal.tsx | 14 ++++-
2 files changed, 80 insertions(+), 14 deletions(-)
diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts
index 9799bcf78..58fe6e994 100644
--- a/src/app/api/rooms/route.ts
+++ b/src/app/api/rooms/route.ts
@@ -1,32 +1,90 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { createRoom, getRoomsForUser } from '@/lib/supabase-rooms';
+import { supabaseAdmin } from '@/lib/supabase-admin';
import { NextResponse } from 'next/server';
import type { CreateRoomPayload } from '@/types/rooms';
+const MAX_ROOMS_PER_USER = 20;
+const MAX_NAME_LEN = 100;
+const MAX_DESCRIPTION_LEN = 500;
+
+// GitHub enforces username โค 39 chars and repo name โค 100 chars.
+const GITHUB_USERNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
+const GITHUB_REPO_RE = /^[a-zA-Z0-9._-]{1,100}$/;
+
export async function GET() {
const session = await getServerSession(authOptions);
- if (!session?.githubLogin)
+ if (!session?.user?.name)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
- const rooms = await getRoomsForUser(session.githubLogin);
+ const rooms = await getRoomsForUser(session.user.name);
return NextResponse.json(rooms);
- } catch (err: any) {
- return NextResponse.json({ error: err.message }, { status: 500 });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Internal server error';
+ return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
- if (!session?.githubLogin)
+ if (!session?.user?.name)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const body: CreateRoomPayload = await req.json();
- if (!body.name?.trim() || !body.repo_owner?.trim() || !body.repo_name?.trim())
- return NextResponse.json({ error: 'name, repo_owner, and repo_name are required' }, { status: 400 });
+
+ let body: CreateRoomPayload;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ const name = body.name?.trim() ?? '';
+ const repoOwner = body.repo_owner?.trim() ?? '';
+ const repoName = body.repo_name?.trim() ?? '';
+ const description = body.description?.trim() ?? '';
+
+ if (!name)
+ return NextResponse.json({ error: 'name is required' }, { status: 400 });
+ if (name.length > MAX_NAME_LEN)
+ return NextResponse.json({ error: `name must be ${MAX_NAME_LEN} characters or fewer` }, { status: 400 });
+
+ if (!repoOwner)
+ return NextResponse.json({ error: 'repo_owner is required' }, { status: 400 });
+ if (!GITHUB_USERNAME_RE.test(repoOwner))
+ return NextResponse.json({ error: 'repo_owner must be a valid GitHub username (1โ39 alphanumeric characters or hyphens, cannot start or end with a hyphen)' }, { status: 400 });
+
+ if (!repoName)
+ return NextResponse.json({ error: 'repo_name is required' }, { status: 400 });
+ if (!GITHUB_REPO_RE.test(repoName))
+ return NextResponse.json({ error: 'repo_name must be a valid GitHub repository name (1โ100 characters, alphanumeric, hyphens, underscores, or dots)' }, { status: 400 });
+
+ if (description.length > MAX_DESCRIPTION_LEN)
+ return NextResponse.json({ error: `description must be ${MAX_DESCRIPTION_LEN} characters or fewer` }, { status: 400 });
+
+ // Enforce per-user room ownership cap.
+ const { count, error: countError } = await supabaseAdmin
+ .from('room_members')
+ .select('*', { count: 'exact', head: true })
+ .eq('github_username', session.user.name)
+ .eq('role', 'owner');
+
+ if (countError)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+
+ if ((count ?? 0) >= MAX_ROOMS_PER_USER)
+ return NextResponse.json(
+ { error: `You can own at most ${MAX_ROOMS_PER_USER} rooms` },
+ { status: 429 }
+ );
+
try {
- const room = await createRoom(body, session.githubLogin);
+ const room = await createRoom(
+ { name, repo_owner: repoOwner, repo_name: repoName, description: description || undefined },
+ session.user.name
+ );
return NextResponse.json(room, { status: 201 });
- } catch (err: any) {
- return NextResponse.json({ error: err.message }, { status: 500 });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Internal server error';
+ return NextResponse.json({ error: message }, { status: 500 });
}
}
diff --git a/src/components/rooms/CreateRoomModal.tsx b/src/components/rooms/CreateRoomModal.tsx
index 23607a823..54f0b5849 100644
--- a/src/components/rooms/CreateRoomModal.tsx
+++ b/src/components/rooms/CreateRoomModal.tsx
@@ -41,7 +41,7 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
onCreated(data);
onClose();
- } catch (err) {
+ } catch {
setError('Failed to create room');
} finally {
setLoading(false);
@@ -62,6 +62,7 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
placeholder="e.g. Frontend Team"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
+ maxLength={100}
required
/>
@@ -73,6 +74,9 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
placeholder="e.g. vercel"
value={form.repo_owner}
onChange={(e) => setForm({ ...form, repo_owner: e.target.value })}
+ maxLength={39}
+ pattern="[a-zA-Z0-9]([a-zA-Z0-9\-]{0,37}[a-zA-Z0-9])?|[a-zA-Z0-9]"
+ title="Valid GitHub username: 1โ39 alphanumeric characters or hyphens, cannot start or end with a hyphen"
required
/>
@@ -84,6 +88,9 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
placeholder="e.g. next.js"
value={form.repo_name}
onChange={(e) => setForm({ ...form, repo_name: e.target.value })}
+ maxLength={100}
+ pattern="[a-zA-Z0-9._\-]{1,100}"
+ title="Valid GitHub repository name: 1โ100 alphanumeric characters, hyphens, underscores, or dots"
required
/>
@@ -96,6 +103,7 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
placeholder="What is this room for?"
value={form.description ?? ''}
onChange={(e) => setForm({ ...form, description: e.target.value })}
+ maxLength={500}
/>
@@ -120,7 +128,7 @@ export default function CreateRoomModal({ onClose, onCreated }: Props) {
-
+
>
);
-}
\ No newline at end of file
+}
From 1d6138d71f27a0b1e80347d152f5d71063b52deb Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:12:03 +0530
Subject: [PATCH 11/41] fix(rooms): show error feedback when a room message
fails to send (#2220)
---
src/components/rooms/MessageInput.tsx | 83 ++++++++++++++++-----------
1 file changed, 50 insertions(+), 33 deletions(-)
diff --git a/src/components/rooms/MessageInput.tsx b/src/components/rooms/MessageInput.tsx
index 191672636..1fcc4bded 100644
--- a/src/components/rooms/MessageInput.tsx
+++ b/src/components/rooms/MessageInput.tsx
@@ -11,24 +11,34 @@ interface Props {
export default function MessageInput({ roomId, onSent }: Props) {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!content.trim() || sending) return;
setSending(true);
- const res = await fetch(`/api/rooms/${roomId}/messages`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: content.trim() }),
- });
+ setError(null);
- setSending(false);
+ try {
+ const res = await fetch(`/api/rooms/${roomId}/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: content.trim() }),
+ });
- if (res.ok) {
- const message = await res.json();
- onSent(message);
- setContent('');
+ if (res.ok) {
+ const message = await res.json();
+ onSent(message);
+ setContent('');
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setError((data as { error?: string }).error ?? 'Failed to send message. Please try again.');
+ }
+ } catch {
+ setError('Network error. Check your connection and try again.');
+ } finally {
+ setSending(false);
}
}
@@ -40,27 +50,34 @@ export default function MessageInput({ roomId, onSent }: Props) {
}
return (
-
+
+ {error && (
+
+ {error}
+
+ )}
+
+
);
-}
\ No newline at end of file
+}
From 3c6c43a859bc934c81c3d1416a53cd8362f22125 Mon Sep 17 00:00:00 2001
From: Priyanshu Doshi
Date: Tue, 9 Jun 2026 22:12:16 +0530
Subject: [PATCH 12/41] feat(rooms): add Leave Room for members and Remove
Member for owners (#2221)
---
.../[roomId]/members/[username]/route.ts | 42 +++++++++++++++++++
src/app/rooms/[roomId]/RoomClient.tsx | 27 +++++++++++-
src/components/rooms/MembersPanel.tsx | 42 ++++++++++++++++---
src/lib/supabase-rooms.ts | 9 ++++
4 files changed, 114 insertions(+), 6 deletions(-)
create mode 100644 src/app/api/rooms/[roomId]/members/[username]/route.ts
diff --git a/src/app/api/rooms/[roomId]/members/[username]/route.ts b/src/app/api/rooms/[roomId]/members/[username]/route.ts
new file mode 100644
index 000000000..21e9b30d9
--- /dev/null
+++ b/src/app/api/rooms/[roomId]/members/[username]/route.ts
@@ -0,0 +1,42 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { getRoomById, removeRoomMember } from '@/lib/supabase-rooms';
+import { NextResponse } from 'next/server';
+
+export async function DELETE(
+ _req: Request,
+ { params }: { params: { roomId: string; username: string } }
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.githubLogin)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const room = await getRoomById(params.roomId, session.githubLogin);
+ if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
+
+ const targetUsername = params.username.trim().toLowerCase();
+ const currentUser = session.githubLogin.toLowerCase();
+
+ if (!targetUsername)
+ return NextResponse.json({ error: 'Invalid username' }, { status: 400 });
+
+ const isSelf = targetUsername === currentUser;
+ const isOwner = room.is_owner;
+
+ if (!isSelf && !isOwner)
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ if (isSelf && isOwner)
+ return NextResponse.json(
+ { error: 'Room owner cannot leave. Delete the room to remove it for everyone.' },
+ { status: 400 }
+ );
+
+ try {
+ await removeRoomMember(params.roomId, params.username.trim());
+ return NextResponse.json({ success: true });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Internal server error';
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/src/app/rooms/[roomId]/RoomClient.tsx b/src/app/rooms/[roomId]/RoomClient.tsx
index a905c5f90..d1a6e2839 100644
--- a/src/app/rooms/[roomId]/RoomClient.tsx
+++ b/src/app/rooms/[roomId]/RoomClient.tsx
@@ -54,6 +54,10 @@ export default function RoomClient({
]);
}
+ function handleMemberRemoved(username: string) {
+ setMembers((prev) => prev.filter((m) => m.github_username !== username));
+ }
+
async function handleDeleteRoom() {
if (!confirm('Are you sure you want to delete this room? This cannot be undone.')) return;
const res = await fetch(`/api/rooms/${room.id}`, { method: 'DELETE' });
@@ -65,6 +69,19 @@ export default function RoomClient({
}
}
+ async function handleLeaveRoom() {
+ if (!confirm('Are you sure you want to leave this room?')) return;
+ const res = await fetch(`/api/rooms/${room.id}/members/${encodeURIComponent(currentUser)}`, {
+ method: 'DELETE',
+ });
+ if (res.ok) {
+ router.push('/rooms');
+ } else {
+ const data = await res.json();
+ alert(data.error ?? 'Failed to leave room');
+ }
+ }
+
return (
@@ -86,13 +103,20 @@ export default function RoomClient({
- {room.is_owner && (
+ {room.is_owner ? (
+ ) : (
+
)}
@@ -111,6 +135,7 @@ export default function RoomClient({
members={members}
isOwner={room.is_owner}
onMemberAdded={handleMemberAdded}
+ onMemberRemoved={handleMemberRemoved}
/>
diff --git a/src/components/rooms/MembersPanel.tsx b/src/components/rooms/MembersPanel.tsx
index d6d20520d..24bc263de 100644
--- a/src/components/rooms/MembersPanel.tsx
+++ b/src/components/rooms/MembersPanel.tsx
@@ -9,10 +9,33 @@ interface Props {
members: RoomMember[];
isOwner: boolean;
onMemberAdded: (username: string) => void;
+ onMemberRemoved: (username: string) => void;
}
-export default function MembersPanel({ roomId, members, isOwner, onMemberAdded }: Props) {
+export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, onMemberRemoved }: Props) {
const [showInvite, setShowInvite] = useState(false);
+ const [removingUsername, setRemovingUsername] = useState(null);
+
+ async function handleRemove(username: string) {
+ if (!confirm(`Remove ${username} from this room?`)) return;
+ setRemovingUsername(username);
+ try {
+ const res = await fetch(
+ `/api/rooms/${roomId}/members/${encodeURIComponent(username)}`,
+ { method: 'DELETE' }
+ );
+ if (res.ok) {
+ onMemberRemoved(username);
+ } else {
+ const data = await res.json().catch(() => ({}));
+ alert((data as { error?: string }).error ?? 'Failed to remove member');
+ }
+ } catch {
+ alert('Network error. Please try again.');
+ } finally {
+ setRemovingUsername(null);
+ }
+ }
return (
{/* Sync Error */}
@@ -557,7 +559,9 @@ export default function GoalTracker() {
{/* Manual +1 only for non-auto-synced goals */}
{!isAutoSynced && (
-
+
)}
{/* ๐ฏ Clean interception: Clicking trash icon sets confirmingId instead of trigger-deleting */}
-
+
@@ -635,13 +639,14 @@ export default function GoalTracker() {
{goal.is_public && (
-
+
)}
@@ -773,10 +778,9 @@ export default function GoalTracker() {
)}
-
+
{createError && (
{createError}
)}
diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx
index c1e168034..61da47031 100644
--- a/src/components/SignOutButton.tsx
+++ b/src/components/SignOutButton.tsx
@@ -2,6 +2,7 @@
import { useState } from "react"
import { signOut } from "next-auth/react"
+import { Button } from "@/components/ui/button"
export default function SignOutButton() {
const [signingOut, setSigningOut] = useState(false)
@@ -21,37 +22,34 @@ export default function SignOutButton() {
if (confirming) {
return (
-
+
-
+
)
}
return (
-
)
}
diff --git a/src/components/repo-analytics/RepoCard.tsx b/src/components/repo-analytics/RepoCard.tsx
index c185c1f7b..beef2a7be 100644
--- a/src/components/repo-analytics/RepoCard.tsx
+++ b/src/components/repo-analytics/RepoCard.tsx
@@ -13,6 +13,7 @@ import {
formatRelativeDate,
formatDate,
} from "@/lib/repoAnalyticsUtils";
+import { Button, buttonVariants } from "@/components/ui/button";
interface RepoCardProps {
repo: ExplorerRepoCardData;
@@ -140,18 +141,17 @@ export default function RepoCard({
href={repo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center justify-center rounded-2xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium text-[var(--card-foreground)] transition hover:bg-[color:color-mix(in_srgb,var(--card)_80%,var(--accent)_20%)]"
+ className={buttonVariants({ variant: "outline" })}
>
Repo
-
+
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index eb9cfe5c5..37514b501 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -3,26 +3,26 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50",
+ "inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-semibold transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] focus-visible:ring-[var(--accent)] disabled:pointer-events-none disabled:opacity-50 gap-2 active:scale-[0.98]",
{
variants: {
variant: {
default:
"bg-[var(--accent)] text-[var(--accent-foreground)] shadow hover:opacity-90",
destructive:
- "bg-[var(--destructive)] text-white shadow-sm hover:opacity-90",
+ "border border-[var(--destructive)]/50 bg-[var(--destructive)]/80 text-white shadow hover:bg-[var(--destructive)]",
outline:
- "border border-[var(--border)] bg-[var(--background)] shadow-sm hover:bg-[var(--card-muted)] hover:text-[var(--foreground)]",
+ "border border-[var(--border)] bg-[var(--background)] shadow-sm hover:border-[var(--accent)] hover:text-[var(--accent)] hover:bg-[var(--card-muted)]/50",
secondary:
- "bg-[var(--card-muted)] text-[var(--foreground)] shadow-sm hover:opacity-80",
- ghost: "hover:bg-[var(--card-muted)] hover:text-[var(--foreground)]",
+ "bg-[var(--card-muted)] text-[var(--foreground)] border border-[var(--border)] shadow-sm hover:opacity-80",
+ ghost: "hover:text-[var(--destructive)] transition-colors",
link: "text-[var(--accent)] underline-offset-4 hover:underline",
},
size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-lg px-3",
+ lg: "h-11 rounded-xl px-8",
+ icon: "h-10 w-10",
},
},
defaultVariants: {
diff --git a/tests/snapshots/visual-regression.spec.js/landing-page-dark.png b/tests/snapshots/visual-regression.spec.js/landing-page-dark.png
index 173ae41108dd5e2cf8a64d0319ba76d16f74c8f2..1a934acea4b276c3f1cbcad54cfcbb129f7f0370 100644
GIT binary patch
literal 279797
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 |