diff --git a/docs/TEXTEXTRACTOR_SETUP.md b/docs/TEXTEXTRACTOR_SETUP.md new file mode 100644 index 0000000..a751bd7 --- /dev/null +++ b/docs/TEXTEXTRACTOR_SETUP.md @@ -0,0 +1,159 @@ +# TEXTEXTRACTOR — Full setup and usage guide + +This guide walks you through the **entire** TEXTEXTRACTOR flow: what it does, how to use the API, how it’s built, and optional external services for better handwriting support. + +--- + +## What it does + +- **Typed/digital PDFs** + Extracts text from the PDF’s built-in text layer (no OCR). Fast and accurate. + +- **Scanned / handwritten PDFs** + When there’s little or no text in the file, the pipeline falls back to **OCR**: + 1. Renders PDF pages to images (using `pdf2pic`). + 2. Runs **Tesseract.js** on each image to recognize text (including handwriting, with varying quality). + +So one API handles both: you send a PDF and get back plain text, with an indication of whether it came from the text layer or OCR. + +--- + +## Using the API + +### Endpoint + +- **URL:** `POST /api/textextractor` +- **Auth:** None required for this route (add auth in your app if you want). +- **Body:** `multipart/form-data` with a PDF file. + +### Form fields + +| Field | Required | Description | +|------------------|----------|-------------| +| `file` or `pdf` | Yes | The PDF file. | +| `force_ocr` | No | If `"true"` or `"1"`, skip typed extraction and run OCR only (useful for known scanned docs). | +| `max_ocr_pages` | No | Max number of pages to run OCR on (default 10, cap 50). | + +### Example (curl) + +```bash +curl -X POST http://localhost:3000/api/textextractor \ + -F "file=@/path/to/notes.pdf" +``` + +Force OCR for all (e.g. scanned) PDFs: + +```bash +curl -X POST http://localhost:3000/api/textextractor \ + -F "file=@/path/to/scan.pdf" \ + -F "force_ocr=true" +``` + +### Example (JavaScript) + +```javascript +const form = new FormData(); +form.append("file", pdfFile); // File from or fetch + +const res = await fetch("/api/textextractor", { + method: "POST", + body: form, +}); +const data = await res.json(); + +if (!res.ok) { + console.error(data.error, data.details); + return; +} + +console.log("Method:", data.method); // "typed" or "ocr" +console.log("Text:", data.text); +console.log("Pages processed:", data.pages_processed); +console.log("Total pages:", data.total_pages); +if (data.ocr_confidence != null) { + console.log("OCR confidence:", data.ocr_confidence); +} +``` + +### Response shape + +- **Success (200):** + - `text` (string) — Extracted text. + - `method` (`"typed"` | `"ocr"`) — How the text was obtained. + - `pages_processed` (number) — Pages used for extraction/OCR. + - `total_pages` (number) — Total pages in the PDF. + - `ocr_confidence` (number, optional) — Only when `method === "ocr"`; average confidence from Tesseract (0–100). + +- **Error (4xx/5xx):** + - `error` (string) — Short message. + - `details` (string, optional) — Extra info (e.g. stack or internal message). + +--- + +## How it’s built (in this repo) + +1. **Dependencies** + - **pdfjs-dist** — Reads the text layer from a PDF buffer (typed PDFs); already in the project. + - **pdf2pic** — Renders PDF pages to PNG/JPEG (already used for previews). + - **tesseract.js** — OCR on images (works in Node; no separate Tesseract install). + +2. **Library: `frontend/lib/textextractor.ts`** + - `extractTyped(buffer)` — Uses `pdfjs-dist` to get text and page count. + - `extractOcr(pdfPath, maxPages)` — Uses `pdf2pic` + Tesseract.js to OCR pages (writes PDF to a temp file because `pdf2pic` needs a path). + - `extract(buffer, options)` — Tries typed first; if the average text per page is below a threshold (default 20 chars), runs OCR. Options: `maxOcrPages`, `minCharsPerPage`, `forceOcr`. + +3. **API route: `frontend/app/api/textextractor/route.ts`** + - Accepts `multipart/form-data` with `file` or `pdf`. + - Validates type (PDF) and size (max 25 MB). + - Calls `extract()` and returns the JSON above. + +No external API keys are required for the built-in flow. + +--- + +## Optional: external APIs for better handwriting + +The built-in pipeline uses **Tesseract.js** for OCR. It’s free and runs locally but handwriting quality can be mixed. If you need better results on handwritten notes, you can: + +1. **Keep the current API** for typed PDFs and “good enough” handwritten/scanned text. +2. **Add an optional external OCR step** (e.g. only when `force_ocr=true` or when Tesseract confidence is low) that calls one of the services below and then merge or replace the text. + +### Free / freemium options + +| Service | Free tier / notes | +|----------------------|--------------------| +| **Google Cloud Vision** | $300 free credit; Document AI has document + handwriting models. | +| **OCR.space** | 25,000 requests/month free; Engine 3 supports handwriting; API key required. | +| **OCRAPI.cloud** | 250 requests/month free; supports handwritten + typed. | +| **Azure Document Intelligence** | Free tier (e.g. 500 pages/month); good for documents and handwriting. | + +### Wiring an external OCR provider + +1. Add an env var for the API key (e.g. `OCR_API_KEY`, `GOOGLE_VISION_KEY`). +2. In `lib/textextractor.ts` (or a separate `lib/ocr-external.ts`): + - When you want to use the external API (e.g. `forceOcr` and key is set), render pages with `pdf2pic`, then send each image (or a multi-page PDF) to the provider’s REST API. + - Map their response to your existing `ExtractResult` shape (e.g. set `method: "ocr"`, `text`, `pagesProcessed`, optional `ocrConfidence`). +3. In the API route, pass a flag or option so the extractor can choose “internal OCR vs external OCR” (e.g. query param `ocr=external` or env-based). + +This way you keep one **TEXTEXTRACTOR** API; only the backend implementation of “OCR path” changes when the key is set. + +--- + +## Limits and tips + +- **Size:** Max 25 MB per PDF (configurable in `route.ts`). +- **OCR pages:** Default max 10 pages for OCR; use `max_ocr_pages` to change (capped at 50) to balance speed and cost if you later add a paid API. +- **Handwriting:** Tesseract.js is best on clear, printed text; handwriting support is limited. For heavy handwriting, use an external API as above. +- **Performance:** Typed extraction is quick; OCR is slower (especially first run while Tesseract.js loads language data). Consider a “processing” status in the UI for large or OCR-only requests. + +--- + +## Quick checklist + +- [ ] Run `npm install` in `frontend` (adds `tesseract.js`; `pdfjs-dist` and `pdf2pic` are already present). +- [ ] Start dev server: `npm run dev` in `frontend`. +- [ ] Call `POST /api/textextractor` with a PDF in `file` or `pdf`. +- [ ] (Optional) Add auth to the route if the app is not public. +- [ ] (Optional) Integrate an external OCR API for better handwriting and set env keys. + +You now have a single API that analyzes text from both typed and handwritten/typed PDF notes, with the option to plug in a stronger OCR service later. diff --git a/frontend/app/api/admin/approve-latest-resource/route.ts b/frontend/app/api/admin/approve-latest-resource/route.ts new file mode 100644 index 0000000..cbf808f --- /dev/null +++ b/frontend/app/api/admin/approve-latest-resource/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient } from "@supabase/supabase-js"; +import { createClient as createServerClient } from "@/utils/supabaseServerClient"; + +type ResourceRow = { + id: string; + title: string; + status: string; +}; + +export async function POST() { + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceRoleKey) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + const supabase = await createServerClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }); + } + + const adminClient = createClient(supabaseUrl, supabaseServiceRoleKey); + + const { data: roles } = await adminClient + .from("user_roles") + .select("role") + .eq("profile_id", user.id) + .in("role", ["admin", "moderator", "developer"]); + + if (!roles || roles.length === 0) { + return NextResponse.json( + { error: "Only admins or moderators can approve notes." }, + { status: 403 }, + ); + } + + // Find the most recently created pending resource and approve it. + const { data: rows, error: listError } = await adminClient + .from("resources") + .select("id, title, status") + .eq("status", "pending") + .order("created_at", { ascending: false }) + .limit(1) + .returns(); + + if (listError) { + return NextResponse.json( + { error: "Failed to list resources.", details: listError.message }, + { status: 500 }, + ); + } + + const resource = rows?.[0]; + if (!resource) { + return NextResponse.json( + { ok: false, message: "No pending resources to approve." }, + { status: 200 }, + ); + } + + const { error: updateError } = await adminClient + .from("resources") + .update({ status: "active" }) + .eq("id", resource.id); + + if (updateError) { + return NextResponse.json( + { error: "Failed to approve resource.", details: updateError.message }, + { status: 500 }, + ); + } + + return NextResponse.json( + { + ok: true, + id: resource.id, + title: resource.title, + previousStatus: resource.status, + newStatus: "active", + }, + { status: 200 }, + ); +} + diff --git a/frontend/app/api/catalog/default-catalog-terms.ts b/frontend/app/api/catalog/default-catalog-terms.ts new file mode 100644 index 0000000..25849ac --- /dev/null +++ b/frontend/app/api/catalog/default-catalog-terms.ts @@ -0,0 +1,21 @@ +/** + * Default catalog terms (2026-2028). Used when catalog_terms table is empty or unavailable. + * Single source of truth for the backend; DB table overrides when populated. + */ +export type CatalogTermItem = { + id: string; + label: string; + term: string; + year: number; +}; + +export const DEFAULT_CATALOG_TERMS: CatalogTermItem[] = [ + { id: "default-fall-2026", label: "Fall 2026", term: "Fall", year: 2026 }, + { id: "default-winter-2027", label: "Winter 2027", term: "Winter", year: 2027 }, + { id: "default-spring-2027", label: "Spring 2027", term: "Spring", year: 2027 }, + { id: "default-summer-2027", label: "Summer 2027", term: "Summer", year: 2027 }, + { id: "default-fall-2027", label: "Fall 2027", term: "Fall", year: 2027 }, + { id: "default-winter-2028", label: "Winter 2028", term: "Winter", year: 2028 }, + { id: "default-spring-2028", label: "Spring 2028", term: "Spring", year: 2028 }, + { id: "default-summer-2028", label: "Summer 2028", term: "Summer", year: 2028 }, +]; diff --git a/frontend/app/api/catalog/terms/route.ts b/frontend/app/api/catalog/terms/route.ts index 159d0f4..2895655 100644 --- a/frontend/app/api/catalog/terms/route.ts +++ b/frontend/app/api/catalog/terms/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { createClient } from "@/utils/supabaseServerClient"; +import { DEFAULT_CATALOG_TERMS } from "../default-catalog-terms"; type TermRow = { id: string; @@ -46,12 +47,13 @@ export async function GET() { return NextResponse.json({ error: error.message }, { status: 500 }); } - const terms = (rows ?? []).map((t) => ({ + const fromDb = (rows ?? []).map((t) => ({ id: t.id, label: t.label, term: t.term, year: t.year, })); + const terms = fromDb.length > 0 ? fromDb : DEFAULT_CATALOG_TERMS; return NextResponse.json({ terms }, { status: 200 }); } diff --git a/frontend/app/api/course-submissions/route.ts b/frontend/app/api/course-submissions/route.ts index dd76601..11564b1 100644 --- a/frontend/app/api/course-submissions/route.ts +++ b/frontend/app/api/course-submissions/route.ts @@ -101,8 +101,7 @@ export async function POST(request: Request) { const resendApiKey = process.env.RESEND_API_KEY?.trim(); const fromEmail = process.env.COURSE_REQUEST_NOTIFY_FROM_EMAIL?.trim(); - const hasEmailConfig = Boolean(notifyEmail && resendApiKey && fromEmail); - if (!hasEmailConfig) { + if (!notifyEmail || !resendApiKey || !fromEmail) { const missing: string[] = []; if (!notifyEmail) missing.push("COURSE_REQUEST_NOTIFY_EMAIL"); if (!resendApiKey) missing.push("RESEND_API_KEY"); diff --git a/frontend/app/api/credits/route.ts b/frontend/app/api/credits/route.ts index 8c8a38b..3a0dd71 100644 --- a/frontend/app/api/credits/route.ts +++ b/frontend/app/api/credits/route.ts @@ -48,10 +48,34 @@ export async function GET() { return NextResponse.json({ error: voucherError.message }, { status: 500 }); } + // count uploads rewarded to this profile + const { count: uploadCount, error: uploadError } = await supabase + .from("credits_ledger") + .select("id", { count: "exact", head: true }) + .eq("profile_id", user.id) + .eq("metadata->>reason", "upload_reward"); + + if (uploadError) { + return NextResponse.json({ error: uploadError.message }, { status: 500 }); + } + + // count upvotes cast by this profile + const { count: upvoteCount, error: upvoteError } = await supabase + .from("votes") + .select("id", { count: "exact", head: true }) + .eq("profile_id", user.id) + .eq("value", 1); + + if (upvoteError) { + return NextResponse.json({ error: upvoteError.message }, { status: 500 }); + } + return NextResponse.json( { credits: creditData?.credit_score ?? 0, freeDownloads: voucherCount ?? 0, + uploadCount: uploadCount ?? 0, + upvoteCount: upvoteCount ?? 0, }, { status: 200 }, ); diff --git a/frontend/app/api/department-submissions/route.ts b/frontend/app/api/department-submissions/route.ts new file mode 100644 index 0000000..5b6c699 --- /dev/null +++ b/frontend/app/api/department-submissions/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { Resend } from "resend"; +import { createClient } from "@/utils/supabaseServerClient"; + +type SubmissionPayload = { + department_name?: string | null; + justification?: string | null; +}; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export async function POST(request: Request) { + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + const status = userError.status === 401 ? 401 : 500; + const message = + userError.status === 401 || userError.message === "Auth session missing!" + ? "Not authenticated" + : userError.message; + return NextResponse.json({ error: message }, { status }); + } + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + let payload: SubmissionPayload; + try { + payload = (await request.json()) as SubmissionPayload; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return NextResponse.json( + { error: "Invalid request payload." }, + { status: 400 } + ); + } + + const departmentName = + typeof payload.department_name === "string" && payload.department_name.trim() + ? payload.department_name.trim() + : ""; + + if (!departmentName) { + return NextResponse.json( + { error: "Department name is required." }, + { status: 400 } + ); + } + + const justification = + typeof payload.justification === "string" && payload.justification.trim() + ? payload.justification.trim() + : null; + + const { error: insertError } = await supabase + .from("department_submissions") + .insert({ + submitter_id: user.id, + department_name: departmentName, + justification, + }); + + if (insertError) { + return NextResponse.json({ error: insertError.message }, { status: 500 }); + } + + const notifyEmail = process.env.COURSE_REQUEST_NOTIFY_EMAIL?.trim(); + const resendApiKey = process.env.RESEND_API_KEY?.trim(); + const fromEmail = process.env.COURSE_REQUEST_NOTIFY_FROM_EMAIL?.trim(); + + const hasEmailConfig = Boolean(notifyEmail && resendApiKey && fromEmail); + if (!hasEmailConfig) { + const missing: string[] = []; + if (!notifyEmail) missing.push("COURSE_REQUEST_NOTIFY_EMAIL"); + if (!resendApiKey) missing.push("RESEND_API_KEY"); + if (!fromEmail) missing.push("COURSE_REQUEST_NOTIFY_FROM_EMAIL"); + console.warn( + "[department-submissions] Moderator email not sent: missing or empty env.", + "Missing:", + missing.join(", ") + ); + } else if ( + notifyEmail === "your-email@example.com" || + resendApiKey === "re_your_api_key_here" + ) { + console.warn( + "[department-submissions] Moderator email not sent: .env.local still has placeholder values." + ); + } else { + try { + const resend = new Resend(resendApiKey!); + const submitterEmail = user.email ?? "(not shared)"; + await resend.emails.send({ + from: fromEmail!, + to: [notifyEmail!], + subject: `[Poly Pages] New department request: ${departmentName}`, + html: [ + "

New department request

", + "

A user has requested a new department be added to the catalog.

", + "", + ``, + justification + ? `` + : "", + ``, + "
Department${escapeHtml(departmentName)}
Justification${escapeHtml(justification)}
Submitter email${escapeHtml(submitterEmail)}
", + "

Poly Pages – Department request notification

", + ].join(""), + }); + } catch (err) { + console.error( + "[department-submissions] Failed to send moderator notification email:", + err + ); + } + } + + return NextResponse.json({ ok: true }, { status: 201 }); +} diff --git a/frontend/app/api/notes/[id]/favorite/route.ts b/frontend/app/api/notes/[id]/favorite/route.ts new file mode 100644 index 0000000..8bdc8cc --- /dev/null +++ b/frontend/app/api/notes/[id]/favorite/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabaseServerClient"; + +async function getAuthenticatedUser() { + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + const status = + userError?.message === "Auth session missing!" || !user + ? 401 + : 500; + return { + ok: false as const, + response: NextResponse.json( + { error: userError?.message ?? "Not authenticated" }, + { status }, + ), + }; + } + + return { ok: true as const, user }; +} + +function getAdminClient() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return null; + } + return createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); +} + +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const auth = await getAuthenticatedUser(); + if (!auth.ok) return auth.response; + + const adminClient = getAdminClient(); + if (!adminClient) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + // Keep exactly one favorite row per user/resource, even without a unique constraint. + const { error: clearError } = await adminClient + .from("resource_favorites") + .delete() + .eq("profile_id", auth.user.id) + .eq("resource_id", resourceId); + + if (clearError) { + return NextResponse.json({ error: clearError.message }, { status: 500 }); + } + + const { error: insertError } = await adminClient + .from("resource_favorites") + .insert({ + profile_id: auth.user.id, + resource_id: resourceId, + }); + + if (insertError) { + return NextResponse.json({ error: insertError.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true, favorited: true }, { status: 200 }); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const auth = await getAuthenticatedUser(); + if (!auth.ok) return auth.response; + + const adminClient = getAdminClient(); + if (!adminClient) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + const { error } = await adminClient + .from("resource_favorites") + .delete() + .eq("profile_id", auth.user.id) + .eq("resource_id", resourceId); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true, favorited: false }, { status: 200 }); +} diff --git a/frontend/app/api/notes/[id]/preview/route.ts b/frontend/app/api/notes/[id]/preview/route.ts new file mode 100644 index 0000000..8f928a3 --- /dev/null +++ b/frontend/app/api/notes/[id]/preview/route.ts @@ -0,0 +1,88 @@ +/** + * GET /api/notes/[id]/preview — Stream the note's preview image or PDF through the backend. + * Requires authentication (cookie or Bearer). No signed storage URL is exposed; inspect cannot + * obtain a shareable or long-lived link to the asset. + */ + +import { NextResponse } from "next/server"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabaseServerClient"; + +type ResourceRow = { + id: string; + preview_key: string | null; + status: string; +}; + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const authHeader = req.headers.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return new NextResponse(null, { status: 401 }); + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return new NextResponse(null, { status: 500 }); + } + + const { data: resource, error: resourceError } = await supabase + .from("resources") + .select("id, preview_key, status") + .eq("id", resourceId) + .single() + .returns(); + + if (resourceError || !resource) { + return new NextResponse(null, { status: 404 }); + } + + if (resource.status !== "active") { + return new NextResponse(null, { status: 403 }); + } + + const pathToStream = resource.preview_key; + if (!pathToStream) { + return new NextResponse(null, { status: 404 }); + } + + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + const { data: fileData, error: fileError } = await adminClient.storage + .from("resources") + .download(pathToStream); + + if (fileError || !fileData) { + return new NextResponse(null, { status: 404 }); + } + + const isPdf = pathToStream.toLowerCase().endsWith(".pdf"); + const contentType = isPdf ? "application/pdf" : (fileData.type || "image/jpeg"); + const buffer = Buffer.from(await fileData.arrayBuffer()); + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "private, max-age=300", + "Content-Disposition": "inline", + }, + }); +} diff --git a/frontend/app/api/notes/[id]/view/route.ts b/frontend/app/api/notes/[id]/view/route.ts new file mode 100644 index 0000000..2bdb6ba --- /dev/null +++ b/frontend/app/api/notes/[id]/view/route.ts @@ -0,0 +1,133 @@ +/** + * GET /api/notes/[id]/view — View the note PDF in-browser (downloaded notes only). + * - Accept: application/json (default) → returns { url: signedUrl }. + * - Accept: application/pdf or ?stream=1 → returns the PDF bytes (for in-app preview, like upload). + */ + +import { NextResponse } from "next/server"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabaseServerClient"; +import { generateSignedUrl } from "@/lib/storage"; + +const VIEW_URL_TTL_SECONDS = 3600; // 1 hour + +type ResourceRow = { + id: string; + file_key: string; + profile_id: string; + status: string; +}; + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const authHeader = req.headers.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }); + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return NextResponse.json( + { error: "Server configuration error." }, + { status: 500 }, + ); + } + + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + + const { data: resource, error: resourceError } = await supabase + .from("resources") + .select("id, file_key, profile_id, status") + .eq("id", resourceId) + .single() + .returns(); + + if (resourceError || !resource) { + return NextResponse.json({ error: "Resource not found." }, { status: 404 }); + } + + if (resource.status !== "active") { + return NextResponse.json( + { error: "Resource is not available for viewing." }, + { status: 403 }, + ); + } + + const isOwner = resource.profile_id === user.id; + let canView = isOwner; + + if (!canView) { + const { data: download } = await supabase + .from("resource_downloads") + .select("id") + .eq("resource_id", resourceId) + .eq("profile_id", user.id) + .maybeSingle(); + canView = Boolean(download?.id); + } + + if (!canView) { + return NextResponse.json( + { error: "Download this note to view it." }, + { status: 403 }, + ); + } + + const acceptPdf = + req.headers.get("accept")?.toLowerCase().includes("application/pdf") ?? false; + const streamParam = new URL(req.url || "", "http://x").searchParams.get("stream"); + const returnStream = acceptPdf || streamParam === "1"; + + if (returnStream) { + const { data: fileData, error: fileError } = await adminClient.storage + .from("resources") + .download(resource.file_key); + + if (fileError || !fileData) { + return NextResponse.json( + { error: "Failed to load PDF." }, + { status: 500 }, + ); + } + + const fileBuffer = Buffer.from(await fileData.arrayBuffer()); + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": fileData.type || "application/pdf", + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=300", + }, + }); + } + + try { + const url = await generateSignedUrl( + "resources", + resource.file_key, + VIEW_URL_TTL_SECONDS, + ); + return NextResponse.json({ url }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to generate view URL."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts index 05464df..8cdb25f 100644 --- a/frontend/app/api/notes/route.ts +++ b/frontend/app/api/notes/route.ts @@ -1,9 +1,14 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { createClient as createSupabaseClient } from "@supabase/supabase-js"; -import { generateSignedUrls } from "@/lib/storage"; import { createClient } from "@/utils/supabaseServerClient"; +const RESOURCE_TYPE_FILTERS = new Set([ + "lecture_notes", + "study_guide", + "class_overview", +]); + type ResourceRow = { id: string; title: string; @@ -13,6 +18,7 @@ type ResourceRow = { file_key: string | null; preview_key: string | null; download_cost: number; + resource_type: string | null; profiles: { display_name: string | null; } | null; @@ -65,6 +71,12 @@ export async function GET(req: Request) { const searchQuery = searchParams.get("search"); const mine = searchParams.get("mine") === "1"; const downloaded = searchParams.get("downloaded") === "1"; + const favorited = searchParams.get("favorited") === "1"; + const resourceTypeParam = searchParams.get("resource_type"); + const resourceType = + resourceTypeParam && RESOURCE_TYPE_FILTERS.has(resourceTypeParam) + ? resourceTypeParam + : null; const page = Number(searchParams.get("page") ?? "1"); const pageSize = Number(searchParams.get("page_size") ?? "16"); const sort = searchParams.get("sort") === "oldest" ? "oldest" : "newest"; @@ -72,6 +84,8 @@ export async function GET(req: Request) { const from = (page - 1) * pageSize; const to = from + pageSize - 1; + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + // When "downloaded=1", fetch resource_ids from resource_downloads first, then query resources. let downloadedResourceIds: string[] = []; if (downloaded) { @@ -88,6 +102,25 @@ export async function GET(req: Request) { } } + // When "favorited=1", fetch resource_ids from resource_favorites first, then query resources. + let favoritedResourceIds: string[] = []; + if (favorited) { + const { data: favoritesData, error: favoritesError } = await adminClient + .from("resource_favorites") + .select("resource_id") + .eq("profile_id", user.id); + if (favoritesError) { + return NextResponse.json({ error: favoritesError.message }, { status: 500 }); + } + favoritedResourceIds = (favoritesData ?? []).map((d: { resource_id: string }) => d.resource_id); + if (favoritedResourceIds.length === 0) { + return NextResponse.json( + { notes: [], page, pageSize, total: 0, hasMore: false }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ); + } + } + let query = supabase .from("resources") .select( @@ -100,6 +133,7 @@ export async function GET(req: Request) { file_key, preview_key, download_cost, + resource_type, profiles ( display_name ) `, ) @@ -111,11 +145,19 @@ export async function GET(req: Request) { if (downloaded) { query = query.in("id", downloadedResourceIds); } + if (favorited) { + query = query.in("id", favoritedResourceIds); + } + if (resourceType) { + query = query.eq("resource_type", resourceType); + } - // Full-text search using the FTS index (searches title and description) + // Keyword search: title, description, and extracted PDF text (typed or OCR) if (searchQuery && searchQuery.trim()) { const searchTerm = `%${searchQuery.trim()}%`; - query = query.or(`title.ilike.${searchTerm},description.ilike.${searchTerm}`); + query = query.or( + `title.ilike.${searchTerm},description.ilike.${searchTerm},extracted_text.ilike.${searchTerm}` + ); } query = query.order("created_at", { ascending: sort === "oldest" }); @@ -173,7 +215,6 @@ export async function GET(req: Request) { const currentUserId = user.id; const downloadedIds = new Set(); if (ids.length > 0) { - const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); const { data: downloadsData } = await adminClient .from("resource_downloads") .select("resource_id") @@ -184,27 +225,27 @@ export async function GET(req: Request) { }); } - const previewPaths = Array.from( - new Set( - pageRows - .map((row) => row.preview_key ?? row.file_key) - .filter((path): path is string => Boolean(path)), - ), - ); - - let previewUrlMap = new Map(); - if (previewPaths.length > 0) { - try { - previewUrlMap = await generateSignedUrls("resources", previewPaths); - } catch { - previewUrlMap = new Map(); - } + const favoritedIds = new Set(); + if (ids.length > 0) { + const { data: favoritesData } = await adminClient + .from("resource_favorites") + .select("resource_id") + .eq("profile_id", currentUserId) + .in("resource_id", ids); + (favoritesData ?? []).forEach((d: { resource_id: string }) => { + favoritedIds.add(d.resource_id); + }); } + // Preview URLs go through our backend proxy (GET /api/notes/[id]/preview) so no signed + // storage URL is ever sent to the client; auth is required and inspect cannot get a shareable link. const normalized = pageRows.map((row) => { const stats = voteMap.get(row.id) ?? { upvotes: 0, downvotes: 0, score: 0 }; const myVote = myVoteMap.get(row.id) ?? null; - const path = row.preview_key ?? row.file_key; + const hasPreview = Boolean(row.preview_key); + const previewUrl = hasPreview ? `/api/notes/${row.id}/preview` : null; + const previewIsPdf = + Boolean(row.preview_key?.toLowerCase().endsWith(".pdf")); return { id: row.id, @@ -213,6 +254,7 @@ export async function GET(req: Request) { created_at: row.created_at, description: row.description ?? null, storage_path: row.file_key, + resource_type: row.resource_type ?? null, profile_display_name: row.profiles?.display_name ?? null, upvote_count: stats.upvotes, downvote_count: stats.downvotes, @@ -220,7 +262,9 @@ export async function GET(req: Request) { my_vote: myVote, download_cost: row.download_cost ?? 0, downloaded: downloadedIds.has(row.id), - previewUrl: path ? previewUrlMap.get(path) ?? null : null, + favorited: favoritedIds.has(row.id), + previewUrl, + previewIsPdf, }; }); diff --git a/frontend/app/api/profile/stats/route.ts b/frontend/app/api/profile/stats/route.ts index 90a6f4a..51384e7 100644 --- a/frontend/app/api/profile/stats/route.ts +++ b/frontend/app/api/profile/stats/route.ts @@ -114,6 +114,7 @@ export async function GET() { const { creditsEarned, creditsSpent } = sumCreditTotals(ledgerRows ?? []); const { rank, totalContributors } = getAllTimeRank(leaderboardRows ?? [], userId); + const profileCreditScore = (profileData as ProfileRow | null)?.credit_score ?? null; return NextResponse.json( { @@ -122,7 +123,7 @@ export async function GET() { totalUpvotes, creditsEarned, creditsSpent, - netCredits: normalizeNetCredits(profileData?.credit_score), + netCredits: normalizeNetCredits(profileCreditScore), }, rank: { allTime: rank, diff --git a/frontend/app/api/textextractor/route.ts b/frontend/app/api/textextractor/route.ts new file mode 100644 index 0000000..d729def --- /dev/null +++ b/frontend/app/api/textextractor/route.ts @@ -0,0 +1,78 @@ +/** + * TEXTEXTRACTOR API — POST a PDF, get extracted text (typed or OCR for handwritten/scanned). + */ + +import { NextRequest, NextResponse } from "next/server"; +import { extract, type ExtractOptions } from "@/lib/textextractor"; + +const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB +const PDF_MIME_TYPES = new Set(["application/pdf"]); + +export async function POST(req: NextRequest) { + const formData = await req.formData(); + const file = formData.get("file") ?? formData.get("pdf"); + const forceOcr = formData.get("force_ocr") === "true" || formData.get("force_ocr") === "1"; + const maxOcrPagesParam = formData.get("max_ocr_pages"); + const maxOcrPages = maxOcrPagesParam ? Math.min(50, Math.max(1, Number(maxOcrPagesParam))) : undefined; + + if (!(file instanceof File)) { + return NextResponse.json( + { error: "A PDF file is required. Send as form field 'file' or 'pdf'." }, + { status: 400 } + ); + } + + const isPdf = + file.type?.toLowerCase() === "application/pdf" || + file.name?.toLowerCase().endsWith(".pdf"); + if (!isPdf) { + return NextResponse.json( + { error: "Only PDF files are supported." }, + { status: 400 } + ); + } + + if (file.size === 0 || file.size > MAX_FILE_SIZE_BYTES) { + return NextResponse.json( + { + error: `File must be between 1 byte and ${MAX_FILE_SIZE_BYTES} bytes.`, + }, + { status: 400 } + ); + } + + let buffer: Buffer; + try { + buffer = Buffer.from(await file.arrayBuffer()); + } catch (e) { + const message = e instanceof Error ? e.message : "Unknown error"; + return NextResponse.json( + { error: "Failed to read file.", details: message }, + { status: 400 } + ); + } + + const options: ExtractOptions = {}; + if (forceOcr) options.forceOcr = true; + if (maxOcrPages != null) options.maxOcrPages = maxOcrPages; + + try { + const result = await extract(buffer, options); + return NextResponse.json({ + text: result.text, + method: result.method, + pages_processed: result.pagesProcessed, + total_pages: result.totalPages, + ...(result.ocrConfidence != null && { + ocr_confidence: Math.round(result.ocrConfidence * 100) / 100, + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Extraction failed"; + console.error("[textextractor]", err); + return NextResponse.json( + { error: "Text extraction failed.", details: message }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/upload/helpers/preview.ts b/frontend/app/api/upload/helpers/preview.ts new file mode 100644 index 0000000..52a0101 --- /dev/null +++ b/frontend/app/api/upload/helpers/preview.ts @@ -0,0 +1,22 @@ +//**Purpose:** Given a path to a PDF file on disk, render the first page to an image, blur it, and return a JPEG +//buffer. + +import sharp from "sharp"; +import { fromPath } from "pdf2pic"; + +const PREVIEW_WIDTH = 400; +const BLUR_SIGMA = 6; + +export async function generateBlurredFirstPageBuffer(pdfPath: string): Promise { + const convert = fromPath(pdfPath, { density: 150 }); + const result = await convert(1, { format: "png" }); + if (!result?.path) throw new Error("Failed to render PDF first page"); + + const blurred = await sharp(result.path) + .resize(PREVIEW_WIDTH) + .blur(BLUR_SIGMA) + .jpeg({ quality: 80 }) + .toBuffer(); + + return blurred; +} \ No newline at end of file diff --git a/frontend/app/api/upload/route.ts b/frontend/app/api/upload/route.ts index f88c0df..ade29fc 100644 --- a/frontend/app/api/upload/route.ts +++ b/frontend/app/api/upload/route.ts @@ -2,14 +2,23 @@ import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { extract } from "@/lib/textextractor"; +import { generateBlurredFirstPageBuffer } from "./helpers/preview"; + +/** Max characters of extracted text to store for search (avoids huge rows). */ +const MAX_EXTRACTED_TEXT_LENGTH = 100_000; + const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const PDF_MIME_TYPES = new Set(["application/pdf"]); +const STORAGE_ALLOWED_MIME_TYPES = new Set(["application/pdf", "image/jpeg"]); const STORAGE_BUCKET = "resources"; const RESOURCE_TYPES = new Set([ "lecture_notes", "study_guide", "class_overview", - "link", ]); const isUuid = (value: string) => @@ -33,23 +42,53 @@ const buildFilePath = (userId: string, originalName: string) => { return `${userId}/${suffix}-${normalizedBase}.pdf`; }; +function buildPreviewPath(pdfFilePath: string): string { + const parts = pdfFilePath.split("/"); + const fileName = parts.pop() ?? ""; + const userId = parts[0] ?? "unknown"; + const baseName = fileName.replace(/\.pdf$/i, ""); + const uuidMatch = baseName.match( + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i + ); + const id = uuidMatch ? uuidMatch[1] : baseName || randomUUID(); + return `previews/${userId}/${id}.jpg`; +} + const ensureResourcesBucket = async (adminClient: SupabaseClient) => { const { data: buckets, error: listError } = await adminClient.storage.listBuckets(); if (listError) { throw listError; } - const exists = buckets?.some((bucket) => bucket.name === STORAGE_BUCKET); - if (exists) return; + const existing = buckets?.find((bucket) => bucket.name === STORAGE_BUCKET); + const desiredMimeTypes = Array.from(STORAGE_ALLOWED_MIME_TYPES); + + if (!existing) { + const { error: createError } = await adminClient.storage.createBucket(STORAGE_BUCKET, { + public: false, + fileSizeLimit: `${MAX_FILE_SIZE_BYTES}`, + allowedMimeTypes: desiredMimeTypes, + }); + + if (createError && !createError.message.toLowerCase().includes("already exists")) { + throw createError; + } + return; + } + + const existingMimeTypes = existing.allowed_mime_types ?? []; + const hasAllMimeTypes = desiredMimeTypes.every((mime) => existingMimeTypes.includes(mime)); + if (hasAllMimeTypes) { + return; + } - const { error: createError } = await adminClient.storage.createBucket(STORAGE_BUCKET, { + const { error: updateError } = await adminClient.storage.updateBucket(STORAGE_BUCKET, { public: false, fileSizeLimit: `${MAX_FILE_SIZE_BYTES}`, - allowedMimeTypes: Array.from(PDF_MIME_TYPES), + allowedMimeTypes: desiredMimeTypes, }); - - if (createError && !createError.message.toLowerCase().includes("already exists")) { - throw createError; + if (updateError) { + throw updateError; } }; @@ -172,6 +211,60 @@ export async function POST(req: NextRequest) { }, }) : adminClient; + + let previewKey: string | null = null; + const tmpPdf = path.join(os.tmpdir(), `upload-${randomUUID()}.pdf`); + try { + fs.writeFileSync(tmpPdf, fileBuffer); + const blurredBuffer = await generateBlurredFirstPageBuffer(tmpPdf); + const previewPath = buildPreviewPath(filePath); + const { error: previewUploadError } = await adminClient.storage + .from(STORAGE_BUCKET) + .upload(previewPath, blurredBuffer, { + cacheControl: "3600", + contentType: "image/jpeg", + upsert: false, + }); + if (previewUploadError) { + console.error("Preview upload failed", previewUploadError); + } else { + previewKey = previewPath; + } + } catch (previewErr) { + console.error("Preview generation failed", previewErr); + } finally { + if (fs.existsSync(tmpPdf)) { + fs.unlinkSync(tmpPdf); + } + } + + // If blurred preview failed, fall back to file_key so we still have a preview_key (required by schema). + // Notes API will return a signed URL for the PDF and the UI can show a blurred PDF preview. + if (previewKey == null) { + previewKey = filePath; + } + + let extractedText: string | null = null; + try { + const result = await extract(fileBuffer, { maxOcrPages: 10 }); + if (result.text?.trim()) { + extractedText = + result.text.length > MAX_EXTRACTED_TEXT_LENGTH + ? result.text.slice(0, MAX_EXTRACTED_TEXT_LENGTH) + : result.text; + } + } catch (extractErr) { + console.warn("[upload] Text extraction failed, storing without extracted_text:", extractErr); + } + + // Only admin/moderator/developer roles get notes auto-approved (for test notes); others stay pending for moderator review. + let status: "pending" | "active" = "pending"; + const { data: roles } = await adminClient + .from("user_roles") + .select("role") + .eq("profile_id", userId) + .in("role", ["admin", "moderator", "developer"]); + if (roles && roles.length > 0) status = "active"; const { data: resource, error: insertError } = await supabase .from("resources") @@ -182,7 +275,9 @@ export async function POST(req: NextRequest) { resource_type: resourceType, description: description || null, file_key: filePath, - preview_key: filePath, + preview_key: previewKey, + ...(status === "active" && { status: "active" }), + ...(extractedText != null && { extracted_text: extractedText }), }) .select() .single(); diff --git a/frontend/app/auth/page.tsx b/frontend/app/auth/page.tsx index 4f6adaf..ebf435d 100644 --- a/frontend/app/auth/page.tsx +++ b/frontend/app/auth/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient"; import "./auth.css"; -export default function AuthPage() { +function AuthPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const redirectTo = searchParams.get("redirect") || "/auth/callback"; @@ -159,3 +159,17 @@ export default function AuthPage() { return
{content}
; } + +export default function AuthPage() { + return ( + +

Loading…

+ + } + > + +
+ ); +} diff --git a/frontend/app/components/DesignNav.tsx b/frontend/app/components/DesignNav.tsx index e0b51db..fdbba05 100644 --- a/frontend/app/components/DesignNav.tsx +++ b/frontend/app/components/DesignNav.tsx @@ -28,9 +28,15 @@ export function DesignNav({
-

- Poly Pages -

+ +

+ Poly Pages +

+
-
- - - -
-
+
+ + setNoteSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (noteSearchDebounceRef.current) { + clearTimeout(noteSearchDebounceRef.current); + noteSearchDebounceRef.current = null; + } + setNoteSearchQuery(noteSearchInput.trim()); + } + }} + className="course-detail-search-input" + aria-label="Search notes by keyword" + /> + {noteSearchInput ? ( + + ) : null} +
Filter:
+
+ Type: + {RESOURCE_TYPE_FILTER_OPTIONS.map((opt) => ( + + ))} +
Sort: +

by {note.profile_display_name ?? "Anonymous"}

@@ -694,7 +1031,7 @@ export default function CourseDetailPage() { {note.downloaded ? "🔓" : "🔒"} {note.downloaded ? "Owned" : `−${note.download_cost} credits`}
- + ))} @@ -751,15 +1088,127 @@ export default function CourseDetailPage() {
-
- {selectedNote.previewUrl ? ( - +
+ {selectedNote.downloaded ? ( + <> + {pdfViewLoading && ( +
+ Loading note… +
+ )} + {pdfViewError && ( +
+ {pdfViewError} +
+ )} + {!pdfViewLoading && !pdfViewError && pdfViewUrl && ( +
+
+ { + setNumPdfPages(numPages); + setPdfPageNumber(1); + }} + loading={ +
+ Loading PDF… +
+ } + error={ +
+ Failed to load PDF +
+ } + > + +
+
+
+ + + Page {pdfPageNumber} of {numPdfPages ?? "…"} + + +
+
+ )} + + ) : selectedNote.previewUrl ? ( + <> + {previewBlobLoading && ( +
+ Loading preview… +
+ )} + {previewBlobError && ( +
+ {previewBlobError} +
+ )} + {!previewBlobLoading && !previewBlobError && previewBlobUrl && ( + selectedNote.previewIsPdf ? ( +