import { NextRequest, NextResponse } from "next/server"; import { requireFamily } from "@/lib/auth"; import { sql } from "@/db"; import { generateThumbnail } from "@/lib/media/thumbnail"; import { processMemoryVision } from "@/lib/ai/vision"; import { S3Client, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { reconcileActualSize } from "@/lib/quota"; function makeR2Client() { const accountId = process.env.R2_ACCOUNT_ID!; const accessKeyId = process.env.R2_ACCESS_KEY_ID!; const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY!; return new S3Client({ region: "auto", endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId, secretAccessKey }, }); } // POST /api/memories/[id]/confirm — called after client finishes uploading to R2 export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const auth = await requireFamily(); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); const familyId = auth.session!.familyId!; const rows = await sql` SELECT id, r2_key, processing_status, size_bytes FROM memories WHERE id = ${id} AND family_id = ${familyId} LIMIT 1 `; if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { r2_key: r2Key, size_bytes: declaredBytes } = rows[0]; // Fetch actual object size from R2 and reconcile against quota. // This catches cases where the client lied about the file size. let actualBytes: number | null = null; try { const client = makeR2Client(); const head = await client.send( new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: r2Key }) ); actualBytes = head.ContentLength ?? null; } catch { // If HeadObject fails the file didn't land — mark failed and return await sql`UPDATE memories SET processing_status = 'failed', updated_at = now() WHERE id = ${id}`; return NextResponse.json({ error: "File not found in storage after upload" }, { status: 422 }); } // Reconcile actual size: updates size_bytes in DB and checks if family is over quota. // reconcileActualSize() always writes the real byte count regardless of plan. if (actualBytes !== null) { const reconcile = await reconcileActualSize(familyId, id, actualBytes); if (!reconcile.keep) { // Actual size pushed family over quota — delete the object and fail the upload. try { const client = makeR2Client(); await client.send( new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: r2Key }) ); } catch { // Best-effort cleanup; log but don't block the error response. console.error(`[quota] failed to delete over-quota object key=${r2Key}`); } await sql`UPDATE memories SET processing_status = 'failed', updated_at = now() WHERE id = ${id}`; return NextResponse.json( { error: reconcile.reason, reason: "storage_quota_exceeded" }, { status: 402 } ); } } // Material size discrepancy check (client declared much less than actual). // 20 % tolerance — flag in logs but don't block if within quota. if (actualBytes !== null && declaredBytes !== null) { const discrepancy = actualBytes - Number(declaredBytes); if (discrepancy > Number(declaredBytes) * 0.2) { console.warn( `[quota] size discrepancy for memory ${id}: declared=${declaredBytes} actual=${actualBytes} delta=${discrepancy}` ); } } // Mark as processing and start the media pipeline await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`; generateThumbnail(id) .then(() => processMemoryVision(id)) .catch(e => console.error(`[memory pipeline] id=${id}`, e)); return NextResponse.json({ success: true, message: "Processing started", actualBytes }); }