Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
17 changes: 14 additions & 3 deletions client/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@ import { prisma } from "@/lib/prisma";
import { createClient } from "@/lib/supabase/server";
import { supabaseAdmin } from "@/lib/supabase/admin";
import { redirect } from "next/navigation";
import { unstable_cache } from "next/cache";
import Header from "@/components/Header";

export const metadata = { title: "Admin | Recall" };

// Cache only the expensive listUsers call for 5 minutes.
// revalidate = 300 is ineffective here because cookies() forces dynamic rendering.
const getCachedAuthUsers = unstable_cache(
async () => {
const { data, error } = await supabaseAdmin.auth.admin.listUsers({ perPage: 1000 });
return error ? [] : data.users;
},
["admin-auth-users"],
{ revalidate: 300 },
);

export default async function AdminPage() {
// Auth check
const supabase = await createClient();
Expand Down Expand Up @@ -69,9 +81,8 @@ export default async function AdminPage() {

const totalUsers = uniqueUsers.length;

// Fetch emails for all user IDs from Supabase auth
const { data: listData, error: listError } = await supabaseAdmin.auth.admin.listUsers({ perPage: 1000 });
const authUsers = listError ? [] : listData.users;
// Fetch emails for all user IDs from Supabase auth (cached 5 min)
const authUsers = await getCachedAuthUsers();
const emailMap = new Map(authUsers.map((u) => [u.id, u.email ?? u.id]));

return (
Expand Down
7 changes: 3 additions & 4 deletions client/src/app/api/bookmarks/[id]/tags/[tagId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { createClient } from "@/lib/supabase/server";
import { getUserFromRequest } from "@/lib/supabase/get-user";

export async function DELETE(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ id: string; tagId: string }> }
) {
const { id, tagId } = await params;

const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const user = await getUserFromRequest(req);
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

try {
Expand Down
7 changes: 3 additions & 4 deletions client/src/app/api/bookmarks/[id]/tags/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { createClient } from "@/lib/supabase/server";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { Prisma } from "@/generated/prisma";
import { tagRatelimit } from "@/lib/ratelimit";

Expand All @@ -10,14 +10,13 @@ export async function POST(
) {
const { id } = await params;

const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const user = await getUserFromRequest(req);
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

const { success } = await tagRatelimit.limit(user.id);
if (!success)
return NextResponse.json(
{ error: "too many requests slow down" },
{ error: "too many requests - slow down" },
{ status: 429 },
);

Expand Down
28 changes: 3 additions & 25 deletions client/src/app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { isIP } from "net";
import { prisma } from "@/lib/prisma";
import { scrapeUrl } from "@/lib/scraper";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { bookmarkRatelimit } from "@/lib/ratelimit";
import { generateTags } from "@/lib/gemini";

function isPrivateIp(host: string): boolean {
if (isIP(host) === 4) {
const parts = host.split(".").map(Number);
return (
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254)
);
}
if (isIP(host) === 6) {
const lower = host.toLowerCase();
return (
lower === "::1" ||
lower.startsWith("fe80:") ||
lower.startsWith("fc") ||
lower.startsWith("fd")
);
}
return false;
}
import { isPrivateIp } from "@/lib/url-validation";

export async function POST(req: NextRequest) {
const user = await getUserFromRequest(req);
Expand All @@ -37,7 +14,7 @@ export async function POST(req: NextRequest) {
const { success } = await bookmarkRatelimit.limit(user.id);
if (!success)
return NextResponse.json(
{ error: "too many requests slow down" },
{ error: "too many requests - slow down" },
{ status: 429 },
);

Expand Down Expand Up @@ -123,6 +100,7 @@ export async function GET(req: NextRequest) {
const bookmarks = await prisma.bookmark.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 500, // safety cap — pagination should be added when needed
include: { tags: true },
});
return NextResponse.json(bookmarks);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { prisma } from "@/lib/prisma";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { NextRequest } from "next/server";

// DELETE /api/collections/[id]/bookmarks/[bookmarkId] — remove a bookmark from a collection
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string; bookmarkId: string }> }
) {
const user = await getUserFromRequest(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

const { id: collectionId, bookmarkId } = await params;

// Verify collection belongs to user
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
});
if (!collection || collection.userId !== user.id) {
return Response.json({ error: "not found" }, { status: 404 });
}

try {
await prisma.collectionBookmark.delete({
where: { collectionId_bookmarkId: { collectionId, bookmarkId } },
});
} catch (e: unknown) {
if ((e as { code?: string })?.code === "P2025") {
return new Response(null, { status: 204 }); // already removed — idempotent
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Response.json({ error: "failed to remove bookmark from collection" }, { status: 500 });
}

return new Response(null, { status: 204 });
}
52 changes: 52 additions & 0 deletions client/src/app/api/collections/[id]/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { prisma } from "@/lib/prisma";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { NextRequest } from "next/server";

// POST /api/collections/[id]/bookmarks — add a bookmark to a collection
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getUserFromRequest(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

const { id: collectionId } = await params;

// Verify collection belongs to user
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
});
if (!collection || collection.userId !== user.id) {
return Response.json({ error: "not found" }, { status: 404 });
}

let body: { bookmarkId?: unknown };
try {
body = await req.json();
} catch {
return Response.json({ error: "invalid json" }, { status: 400 });
}

const bookmarkId =
typeof body.bookmarkId === "string" ? body.bookmarkId.trim() : "";
if (!bookmarkId) {
return Response.json({ error: "bookmarkId is required" }, { status: 400 });
}

// Verify bookmark belongs to user
const bookmark = await prisma.bookmark.findUnique({
where: { id: bookmarkId },
});
if (!bookmark || bookmark.userId !== user.id) {
return Response.json({ error: "bookmark not found" }, { status: 404 });
}

// Upsert to avoid duplicate errors
await prisma.collectionBookmark.upsert({
where: { collectionId_bookmarkId: { collectionId, bookmarkId } },
create: { collectionId, bookmarkId },
update: {},
});

return new Response(null, { status: 204 });
}
29 changes: 29 additions & 0 deletions client/src/app/api/collections/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { prisma } from "@/lib/prisma";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { NextRequest } from "next/server";

// DELETE /api/collections/[id]
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getUserFromRequest(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

const { id } = await params;

const collection = await prisma.collection.findUnique({ where: { id } });
if (!collection || collection.userId !== user.id) {
return Response.json({ error: "not found" }, { status: 404 });
}

try {
await prisma.collection.delete({ where: { id } });
} catch (e: unknown) {
if ((e as { code?: string })?.code === "P2025") {
return new Response(null, { status: 204 }); // already deleted — idempotent
}
return Response.json({ error: "failed to delete collection" }, { status: 500 });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return new Response(null, { status: 204 });
}
44 changes: 44 additions & 0 deletions client/src/app/api/collections/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/prisma";
import { getUserFromRequest } from "@/lib/supabase/get-user";
import { NextRequest } from "next/server";

// GET /api/collections — list all collections for the user
export async function GET(req: NextRequest) {
const user = await getUserFromRequest(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

const collections = await prisma.collection.findMany({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});

return Response.json(collections);
}

// POST /api/collections — create a new collection
export async function POST(req: NextRequest) {
const user = await getUserFromRequest(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

let body: { name?: unknown };
try {
body = await req.json();
} catch {
return Response.json({ error: "invalid json" }, { status: 400 });
}

const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) return Response.json({ error: "name is required" }, { status: 400 });
if (name.length > 100) return Response.json({ error: "name too long" }, { status: 400 });

let collection;
try {
collection = await prisma.collection.create({
data: { name, userId: user.id },
});
Comment on lines +30 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject duplicate collection names per user.

This still allows the same account to create two collections with the same visible name. DashboardClient renders only col.name, so duplicate pills and delete confirmations become ambiguous very quickly. Please block duplicates here and back that up with user-scoped uniqueness instead of inserting another row.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/collections/route.ts` around lines 30 - 38, Check for an
existing collection with the same name for the current user before creating and
enforce user-scoped uniqueness in the database schema: in route.ts, query
prisma.collection.findFirst or findUnique with where: { userId: user.id, name }
and return a 409/400 JSON error if found, and also add a unique constraint to
the Collection model (@@unique([userId, name]) or equivalent) in the Prisma
schema and run a migration; finally, handle Prisma unique-constraint errors
around prisma.collection.create (catch the specific error code and return the
same 409/400) so creating duplicates is prevented both at the app and DB level.

} catch {
return Response.json({ error: "failed to create collection" }, { status: 500 });
}

return Response.json(collection, { status: 201 });
}
23 changes: 16 additions & 7 deletions client/src/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ import { redirect } from "next/navigation";
export const metadata = { title: "Your Bookmarks | Recall" };

async function DashboardLoader({ userId }: { userId: string }) {
const bookmarks = await prisma.bookmark.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
include: { tags: true },
});
const [bookmarks, collections] = await Promise.all([
prisma.bookmark.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 500,
include: { tags: true, collections: { select: { collectionId: true } } },
}),
prisma.collection.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: { id: true, name: true },
}),
]);

const serialized = bookmarks.map((b) => ({
const serialized = bookmarks.map(({ collections: cols, ...b }) => ({
...b,
createdAt: b.createdAt.toISOString(),
collectionIds: cols.map((c) => c.collectionId),
}));

return <DashboardClient bookmarks={serialized} />;
return <DashboardClient bookmarks={serialized} initialCollections={collections} />;
}

function DashboardSkeleton() {
Expand Down
2 changes: 1 addition & 1 deletion client/src/app/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ function AuthForm() {
)}
{slow && !error && (
<p className="text-xs text-[var(--text-muted)]" aria-live="polite">
Still working this can take a few seconds on first load…
Still working - this can take a few seconds on first load…
</p>
)}

Expand Down
2 changes: 1 addition & 1 deletion client/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" suppressHydrationWarning data-scroll-behavior="smooth">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
Expand Down
Loading
Loading