Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test";

const PORT = Number(process.env.PORT ?? 3000);
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`;
const prepareStandaloneCommand =
"node -e \"const fs=require('fs'); fs.cpSync('public','.next/standalone/public',{recursive:true,force:true}); fs.cpSync('.next/static','.next/standalone/.next/static',{recursive:true,force:true});\"";

export default defineConfig({
testDir: "./e2e",
Expand All @@ -22,7 +24,7 @@ export default defineConfig({
webServer: {
command:
process.env.PLAYWRIGHT_SERVER_MODE === "start"
? "node .next/standalone/server.js"
? `${prepareStandaloneCommand} && node .next/standalone/server.js`
: `node node_modules/next/dist/bin/next dev -H 127.0.0.1 -p ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/local-coding/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,19 @@ export async function POST(req: NextRequest) {
);
}

if ((existingCount || 0) + newSessions.length > MAX_SESSIONS_PER_USER) {
const incomingDates = [...new Set(newSessions.map((session) => session.date))];
const { data: existingSessionsForDates } = await supabaseAdmin
.from("local_coding_sessions")
.select("date")
.eq("user_id", userId)
.in("date", incomingDates);

const existingDateSet = new Set(
(existingSessionsForDates ?? []).map((session: { date: string }) => session.date)
);
const newDateCount = incomingDates.filter((date) => !existingDateSet.has(date)).length;

if ((existingCount || 0) + newDateCount > MAX_SESSIONS_PER_USER) {
return Response.json(
{ error: `Session limit reached. Maximum ${MAX_SESSIONS_PER_USER} sessions per user.` },
{ status: 400 }
Expand Down
25 changes: 21 additions & 4 deletions src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import type { ReactNode } from "react";

async function hasActiveSession(fetcher: typeof window.fetch) {
try {
const response = await fetcher("/api/auth/session", { cache: "no-store" });
if (!response.ok) return false;

const session = await response.json();
return Boolean(session?.user || session?.githubId || session?.accessToken);
} catch {
return false;
}
}

export default function DashboardLayout({ children }: { children: ReactNode }) {
const { status } = useSession({ required: true });
const router = useRouter();
Expand All @@ -16,9 +28,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
const response = await originalFetch(...args);
if (response.status === 401) {
const cloned = response.clone();
toast.error("Session expired. Please sign in again.");
await signOut({ redirect: false });
router.push("/auth/signin");
const sessionStillActive = await hasActiveSession(originalFetch);

if (!sessionStillActive) {
toast.error("Session expired. Please sign in again.");
await signOut({ redirect: false });
router.push("/auth/signin");
}

return cloned;
}
return response;
Expand All @@ -31,4 +48,4 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
if (status === "loading") return null;

return <>{children}</>;
}
}
5 changes: 3 additions & 2 deletions src/components/AppNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function AppNavbar() {
const pathname = usePathname();
const { data: session, status } = useSession();
const [mobileOpen, setMobileOpen] = useState(false);
const isPublicProfilePage = pathname.startsWith("/u/");

useEffect(() => {
setMobileOpen(false);
Expand Down Expand Up @@ -102,7 +103,7 @@ export default function AppNavbar() {
Sign out
</button>
</>
) : (
) : isPublicProfilePage ? null : (
<Link
href="/api/auth/signin/github?callbackUrl=/dashboard"
className="rounded-full bg-[var(--accent)] px-4 py-2 text-sm font-semibold text-[var(--accent-foreground)] transition-opacity hover:opacity-90"
Expand Down Expand Up @@ -161,7 +162,7 @@ export default function AppNavbar() {
Sign out
</button>
</>
) : (
) : isPublicProfilePage ? null : (
<Link
href="/api/auth/signin/github?callbackUrl=/dashboard"
className="rounded-2xl bg-[var(--accent)] px-4 py-3 text-sm font-semibold text-[var(--accent-foreground)]"
Expand Down
15 changes: 14 additions & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,20 @@ async function checkRateLimit(identifier: string, limit: number) {
}

export async function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });

if (pathname === "/dashboard" || pathname.startsWith("/dashboard/")) {
if (!token) {
const url = req.nextUrl.clone();
url.pathname = "/";
url.search = "";
return NextResponse.redirect(url);
}

return NextResponse.next();
}

const githubId = typeof token?.githubId === "string" ? token.githubId : null;
const identifier = githubId ? `user:${githubId}` : `ip:${getIp(req)}`;
const limit = githubId ? AUTHENTICATED_LIMIT : ANONYMOUS_LIMIT;
Expand Down Expand Up @@ -237,5 +250,5 @@ export async function middleware(req: NextRequest) {
}

export const config = {
matcher: "/api/metrics/:path*",
matcher: ["/api/metrics/:path*", "/dashboard", "/dashboard/:path*"],
};
60 changes: 56 additions & 4 deletions test/local-coding-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const mockSelect = vi.fn().mockReturnValue({ eq: mockEq });
const mockKeyLookupOr = vi.fn().mockReturnValue({ single: mockSingle });
const mockUpdateOr = vi.fn().mockResolvedValue({ error: null });
const mockUpdate = vi.fn().mockReturnValue({ or: mockUpdateOr });
const mockSessionCountEq = vi.fn();
const mockExistingDatesIn = vi.fn();
const mockExistingDatesEq = vi.fn().mockReturnValue({ in: mockExistingDatesIn });
const mockFrom = vi.fn().mockReturnValue({
select: mockSelect,
update: mockUpdate,
Expand All @@ -31,6 +34,9 @@ describe("Local Coding Sync POST API Endpoint", () => {
data: { user_id: "test-user-id" },
error: null,
});
mockSessionCountEq.mockResolvedValue({ count: 5, data: null, error: null });
mockExistingDatesIn.mockResolvedValue({ data: [], error: null });
mockExistingDatesEq.mockReturnValue({ in: mockExistingDatesIn });

// Setup standard mock behavior
mockFrom.mockImplementation((table: string) => {
Expand All @@ -46,8 +52,11 @@ describe("Local Coding Sync POST API Endpoint", () => {
}
if (table === "local_coding_sessions") {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: 5, data: null, error: null }),
select: vi.fn((_columns: string, options?: { count?: string; head?: boolean }) => {
if (options?.count) {
return { eq: mockSessionCountEq };
}
return { eq: mockExistingDatesEq };
}),
};
}
Expand Down Expand Up @@ -179,8 +188,15 @@ describe("Local Coding Sync POST API Endpoint", () => {
}
if (table === "local_coding_sessions") {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: 360, data: null, error: null }),
select: vi.fn((_columns: string, options?: { count?: string; head?: boolean }) => {
if (options?.count) {
return { eq: vi.fn().mockResolvedValue({ count: 360, data: null, error: null }) };
}
return {
eq: vi.fn().mockReturnValue({
in: vi.fn().mockResolvedValue({ data: [], error: null }),
}),
};
}),
};
}
Expand Down Expand Up @@ -208,6 +224,42 @@ describe("Local Coding Sync POST API Endpoint", () => {
expect(body.error).toContain("Session limit reached");
});

it("allows near-limit resyncs when incoming dates already exist", async () => {
const existingDates = Array.from({ length: 10 }, (_, i) => `2026-05-${10 + i}`);

mockSessionCountEq.mockResolvedValue({ count: 360, data: null, error: null });
mockExistingDatesIn.mockResolvedValue({
data: existingDates.map((date) => ({ date })),
error: null,
});

const sessions = existingDates.map((date) => ({
date,
totalSeconds: 100,
}));
const req = new NextRequest("http://localhost/api/local-coding/sync", {
method: "POST",
headers: {
Authorization: "Bearer test-key",
},
body: JSON.stringify({ sessions }),
});

const res = await POST(req);

expect(res.status).toBe(200);
expect(mockExistingDatesIn).toHaveBeenCalledWith("date", existingDates);
expect(mockRpc).toHaveBeenCalledWith("batch_upsert_sessions", {
sessions: existingDates.map((date) => ({
user_id: "test-user-id",
date,
total_seconds: 100,
file_count: 0,
project_count: 0,
})),
});
});

it("successfully syncs sessions via batch_upsert_sessions RPC", async () => {
const sessions = [
{ date: "2026-05-27", totalSeconds: 3600, fileCount: 12, projectCount: 3 },
Expand Down
Loading