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>
95 lines
3.9 KiB
TypeScript
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 });
|
|
}
|