Skip to content
Merged
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
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,30 @@ Read `GUIDE.md` first — it contains the full project briefing and teaching rul
|------|--------|
| P2.1 User dashboard (web UI) | ✅ Done |
| P2.2 Admin dashboard | ✅ Done |
| P2.3 Collections (folders) | ⬜ Schema exists, no UI/endpoints |
| P2.3 Collections (folders) | ✅ Done |
| P2.4 AI auto-tagging via Gemini | ✅ Done |
| P2.5 Text search UI improvements | ✅ Done |
| P2.6 Bookmarklet | ⬜ Not started |
| P2.7 Daily digest email (resurfacing V1) | ⬜ Not started |

**P2.7 Daily Digest — implementation plan**
- Vercel cron job hits `/api/digest` daily
- Picks 5 oldest unvisited bookmarks per user
- Sends email via Resend (free tier: 3,000/month)
- Email: title + URL + tags for each bookmark
- Requires: `RESEND_API_KEY` in env, custom domain for sending

**Phase 3 — Advanced**

| Task | Status |
|------|--------|
| P3.1 Semantic / vector search (pgvector) | ⬜ |
| P3.2 AI-suggested collections (Gemini clusters by tags) | ⬜ |
| P3.3 Resurfacing V2 — "you saved this about X, you're saving X again" | ⬜ |
| P3.4 Spaced repetition algorithm for digest | ⬜ |
| P3.5 D3.js knowledge graph (bookmarks as nodes, tags as edges) | ⬜ |
| P3.6 Background queue for async AI tagging | ⬜ |
| P3.7 Open/click tracking on digest emails | ⬜ |

## Project Structure

Expand Down
4 changes: 0 additions & 4 deletions client/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "**",
},
],
},
};
Expand Down
18 changes: 15 additions & 3 deletions client/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ 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 });
if (error) { console.error("getCachedAuthUsers failed:", error); return []; }
return data.users;
},
["admin-auth-users"],
{ revalidate: 300 },
);

export default async function AdminPage() {
// Auth check
const supabase = await createClient();
Expand Down Expand Up @@ -69,9 +82,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, NextResponse } 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 NextResponse.json({ error: "unauthorized" }, { status: 401 });

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

return NextResponse.json(collections);
}

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

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

const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 });
if (name.length > 100) return NextResponse.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 NextResponse.json({ error: "failed to create collection" }, { status: 500 });
}

return NextResponse.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/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ body {
/* ── Landing page font utilities ─────────────────────────────────────────── */
.font-display { font-family: var(--font-display), system-ui, sans-serif; }
.font-sans-lp { font-family: var(--font-sans), system-ui, sans-serif; }
.font-mono-lp { font-family: var(--font-mono), monospace; }
.font-mono-lp { font-family: monospace; }

/* ── Animations ─────────────────────────────────────────────────────────── */
@keyframes fade-up {
Expand Down
Loading
Loading