tia/src/app/api/memories/[id]/confirm/route.ts
Mannu 0c7f37fd12 feat(quota): storage quota + family-member limits for free tier
Feature A — Storage quota (1 GiB per family):
- src/lib/quota.ts: enforcement library with pure functions (fully unit-tested)
  and DB-bound helpers; isPaidFamily() is the single payment abstraction gate
- src/lib/format-bytes.ts: extracted formatBytes() — safe for client imports
- POST /api/upload: quota check before presigned URL issuance (HTTP 402 + reason code)
- POST /api/memories/[id]/confirm: HeadObject reconciles actual R2 size; deletes
  over-quota objects and marks row failed rather than silently exceeding limit
- GET /api/storage-usage: storage info endpoint for UI meter
- src/components/StorageMeter.tsx: meter bar + StorageQuotaBanner + MemberLimitBanner
- memories/page.tsx: quota banner, FAB disabled (⊘) when exceeded, compact meter in header
- settings/page.tsx: always-visible StorageMeter + MemberLimitBanner in invite section

Feature B — Member limit (2 per family, free tier):
- invites/route.ts: replaced ad-hoc inline check with checkMemberLimit() from quota lib
  Structured 403 response: { reason, currentCount, limit }
- Freeze rule: paid→free downgrade leaves all members intact; only new invites blocked

Migration:
- drizzle/0007_subscription_status.sql: ADD COLUMN subscription_status varchar(20)
- debug-migration/route.ts: step added for hot-apply without full redeploy
- src/db/schema/family.ts: subscriptionStatus field added to Drizzle schema

Tests: 44 unit tests in src/__tests__/quota.test.ts, all passing
- Pure function tests (no DB): isPaidFamily, wouldExceedQuota, isAtMemberLimit, formatBytes
- DB-bound tests (mocked @/db): getFamilyStorageUsage, checkStorageQuota,
  checkMemberLimit, getStorageInfo, tenant isolation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:21:11 +05:30

95 lines
3.9 KiB
TypeScript

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 });
}