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
380 changes: 380 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

33 changes: 2 additions & 31 deletions app/(app)/flashcards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,7 @@ import { db } from "@/lib/db";
import { flashcards, note } from "@/lib/db/schema";
import { requireServerSession } from "@/lib/auth-session";
import { FlashcardsClient } from "@/app/flashcards/flashcards-client";
import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types";

function normalizeCards(value: unknown): FlashcardItem[] {
if (!Array.isArray(value)) {
return [];
}

return value.filter((item): item is FlashcardItem => {
if (!item || typeof item !== "object") {
return false;
}

const candidate = item as Partial<FlashcardItem>;
return (
typeof candidate.id === "string" &&
typeof candidate.front === "string" &&
typeof candidate.back === "string" &&
Array.isArray(candidate.sourceNoteTitles) &&
candidate.sourceNoteTitles.every((title) => typeof title === "string")
);
});
}
import { rowToFlashcardDeck } from "@/lib/flashcards/decks";

export default async function FlashcardsPage() {
await connection();
Expand Down Expand Up @@ -57,15 +36,7 @@ export default async function FlashcardsPage() {
.orderBy(desc(flashcards.createdAt)),
]);

const decks: FlashcardDeck[] = deckRows.map((deck) => ({
id: deck.id,
title: deck.title,
sourceNoteIds: deck.sourceNoteIds,
cards: normalizeCards(deck.cards),
cardCount: deck.cardCount,
createdAt: deck.createdAt.toISOString(),
updatedAt: deck.updatedAt.toISOString(),
}));
const decks = deckRows.map(rowToFlashcardDeck);

return (
<FlashcardsClient
Expand Down
223 changes: 161 additions & 62 deletions app/api/flashcards/route.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,68 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { flashcards } from "@/lib/db/schema";
import { createDraftFlashcardDeck, editableDeckSchema, rowToFlashcardDeck } from "@/lib/flashcards/decks";
import { getFlashcardContextNotes } from "@/lib/flashcards/context";
import { generateFlashcardDeck } from "@/lib/flashcards/gemini";
import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types";

export const runtime = "nodejs";

const createFlashcardsSchema = z.object({
const legacyCreateFlashcardsSchema = z.object({
noteIds: z.array(z.string().min(1)).min(1).max(12),
cardCount: z.number().int().min(4).max(40),
});

const flashcardItemSchema = z.object({
id: z.string().min(1),
front: z.string().min(1),
back: z.string().min(1),
sourceNoteTitles: z.array(z.string().min(1)).min(1),
const generatePreviewSchema = legacyCreateFlashcardsSchema.extend({
mode: z.literal("generate"),
});

function normalizeCards(value: unknown): FlashcardItem[] {
const parsed = z.array(flashcardItemSchema).safeParse(value);
return parsed.success ? parsed.data : [];
const saveDeckSchema = editableDeckSchema.extend({
mode: z.literal("save"),
});

const updateDeckSchema = editableDeckSchema.extend({
deckId: z.string().trim().min(1),
});

async function requireSession() {
return auth.api.getSession({ headers: await headers() });
}

function rowToDeck(row: {
id: string;
title: string;
sourceNoteIds: string[];
cards: unknown;
cardCount: number;
createdAt: Date;
updatedAt: Date;
}): FlashcardDeck {
async function generateDeckPreview(params: { userId: string; noteIds: string[]; cardCount: number }) {
const uniqueNoteIds = Array.from(new Set(params.noteIds));
const contextNotes = await getFlashcardContextNotes({
userId: params.userId,
noteIds: uniqueNoteIds,
});

if (contextNotes.length !== uniqueNoteIds.length) {
return { error: "One or more selected notes could not be found", status: 404 } as const;
}

if (contextNotes.every((note) => note.embedding.length === 0)) {
return { error: "Selected notes do not have stored embeddings yet", status: 400 } as const;
}

const generatedDeck = await generateFlashcardDeck({
notes: contextNotes,
cardCount: params.cardCount,
});

return {
id: row.id,
title: row.title,
sourceNoteIds: row.sourceNoteIds,
cards: normalizeCards(row.cards),
cardCount: row.cardCount,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
deck: createDraftFlashcardDeck({
title: generatedDeck.title,
sourceNoteIds: uniqueNoteIds,
cards: generatedDeck.cards,
}),
} as const;
}

export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Expand All @@ -68,11 +81,11 @@ export async function GET() {
.where(eq(flashcards.userId, session.user.id))
.orderBy(desc(flashcards.createdAt));

return NextResponse.json({ decks: rows.map(rowToDeck) });
return NextResponse.json({ decks: rows.map(rowToFlashcardDeck) });
}

export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
const session = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Expand All @@ -84,48 +97,134 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = createFlashcardsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid flashcard generation options" }, { status: 400 });
}

const uniqueNoteIds = Array.from(new Set(parsed.data.noteIds));
const contextNotes = await getFlashcardContextNotes({
userId: session.user.id,
noteIds: uniqueNoteIds,
});

if (contextNotes.length !== uniqueNoteIds.length) {
return NextResponse.json({ error: "One or more selected notes could not be found" }, { status: 404 });
}

if (contextNotes.every((note) => note.embedding.length === 0)) {
return NextResponse.json(
{ error: "Selected notes do not have stored embeddings yet" },
{ status: 400 },
);
}
const generateParsed = generatePreviewSchema.safeParse(body);
const saveParsed = saveDeckSchema.safeParse(body);
const legacyParsed = legacyCreateFlashcardsSchema.safeParse(body);

try {
const generatedDeck = await generateFlashcardDeck({
notes: contextNotes,
cardCount: parsed.data.cardCount,
if (generateParsed.success) {
const result = await generateDeckPreview({
userId: session.user.id,
noteIds: generateParsed.data.noteIds,
cardCount: generateParsed.data.cardCount,
});

if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

return NextResponse.json({ deck: result.deck, saved: false });
}

if (saveParsed.success) {
const [created] = await db
.insert(flashcards)
.values({
userId: session.user.id,
title: saveParsed.data.title,
sourceNoteIds: saveParsed.data.sourceNoteIds,
cards: saveParsed.data.cards,
cardCount: saveParsed.data.cards.length,
})
.returning();

return NextResponse.json({ deck: rowToFlashcardDeck(created) }, { status: 201 });
}

if (!legacyParsed.success) {
return NextResponse.json({ error: "Invalid flashcard request" }, { status: 400 });
}

const result = await generateDeckPreview({
userId: session.user.id,
noteIds: legacyParsed.data.noteIds,
cardCount: legacyParsed.data.cardCount,
});

if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

const [created] = await db
.insert(flashcards)
.values({
userId: session.user.id,
title: generatedDeck.title,
sourceNoteIds: uniqueNoteIds,
cards: generatedDeck.cards,
cardCount: generatedDeck.cards.length,
title: result.deck.title,
sourceNoteIds: result.deck.sourceNoteIds,
cards: result.deck.cards,
cardCount: result.deck.cards.length,
})
.returning();

return NextResponse.json({ deck: rowToFlashcardDeck(created) }, { status: 201 });
} catch (error) {
console.error("[POST /api/flashcards]", error);
return NextResponse.json({ error: "Flashcard generation failed" }, { status: 500 });
}
}

export async function PATCH(req: Request) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

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

const parsed = updateDeckSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid flashcard deck" }, { status: 400 });
}

try {
const [updated] = await db
.update(flashcards)
.set({
title: parsed.data.title,
sourceNoteIds: parsed.data.sourceNoteIds,
cards: parsed.data.cards,
cardCount: parsed.data.cards.length,
updatedAt: new Date(),
})
.where(and(eq(flashcards.id, parsed.data.deckId), eq(flashcards.userId, session.user.id)))
.returning();

return NextResponse.json({ deck: rowToDeck(created) }, { status: 201 });
if (!updated) {
return NextResponse.json({ error: "Flashcard deck not found" }, { status: 404 });
}

return NextResponse.json({ deck: rowToFlashcardDeck(updated) });
} catch (error) {
const message = error instanceof Error ? error.message : "Flashcard generation failed";
return NextResponse.json({ error: message }, { status: 500 });
console.error("[PATCH /api/flashcards]", error);
return NextResponse.json({ error: "Failed to save flashcard deck" }, { status: 500 });
}
}

export async function DELETE(req: Request) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const deckId = new URL(req.url).searchParams.get("deckId");
const parsedDeckId = z.string().trim().min(1).safeParse(deckId);
if (!parsedDeckId.success) {
return NextResponse.json({ error: "Missing flashcard deck id" }, { status: 400 });
}

const [deleted] = await db
.delete(flashcards)
.where(and(eq(flashcards.id, parsedDeckId.data), eq(flashcards.userId, session.user.id)))
.returning();

if (!deleted) {
return NextResponse.json({ error: "Flashcard deck not found" }, { status: 404 });
}

return NextResponse.json({ deletedDeckId: deleted.id });
}
Loading
Loading