Schema (W0): - Add garments, garment_wears, outfits tables with Drizzle migrations - Drizzle migrations 0001 (garments/wears) and 0002 (outfits) auto-apply on deploy - RLS policies in drizzle/manual/06-wardrobe-rls.sql (apply via superuser in prod) API (W1–W9): - POST /api/garments/upload — direct upload to R2 garments/ prefix with sharp thumbnail - POST /api/garments/tag — vision tagging via LiteLLM, defensive parse, category validated - GET/POST /api/garments — list with composable filters, create - GET/PATCH/DELETE /api/garments/[id] — detail, edit, delete - POST /api/garments/[id]/wear — log worn date - GET /api/garments/outgrowth — pure SQL, explicit size ordering (no lexicographic sort) - GET /api/garments/packing — active garments grouped by category - GET /api/garments/outfit — Open-Meteo weather + deterministic outfit pairing, no LLM - GET/POST /api/garments/outfits + DELETE [id] — saved outfits Pages: - /wardrobe — grid with status/category/size/season filters + outgrowth nudge - /wardrobe/add — 3-step capture→vision→form, size required, batch-friendly - /wardrobe/[id] — detail/edit/status lifecycle + wear history - /wardrobe/packing — packing checklist by category - /wardrobe/outfit — weather-aware suggestions with shown basis - /wardrobe/saved-outfits — view/delete saved combinations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { requireFamily } from "@/lib/auth";
|
|
import sharp from "sharp";
|
|
import { randomUUID } from "crypto";
|
|
|
|
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
|
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
|
|
|
function getR2Config() {
|
|
return {
|
|
accountId: process.env.R2_ACCOUNT_ID!,
|
|
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
secretKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
bucket: process.env.R2_BUCKET_NAME!,
|
|
publicUrl: process.env.R2_PUBLIC_URL,
|
|
};
|
|
}
|
|
|
|
function makeClient(R2: ReturnType<typeof getR2Config>) {
|
|
return new S3Client({
|
|
region: "auto",
|
|
endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
|
|
credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey },
|
|
});
|
|
}
|
|
|
|
// POST /api/garments/upload
|
|
// Accepts multipart/form-data with a single "file" field.
|
|
// Returns { imageKey, thumbKey, imageUrl, thumbUrl }
|
|
export async function POST(req: NextRequest) {
|
|
const auth = await requireFamily();
|
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
// family_id comes from session — never from request params
|
|
const familyId = auth.session!.familyId!;
|
|
|
|
const R2 = getR2Config();
|
|
if (!R2.accountId || !R2.accessKeyId || !R2.secretKey || !R2.bucket) {
|
|
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
|
|
}
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await req.formData();
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
|
}
|
|
|
|
const file = formData.get("file") as File | null;
|
|
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
|
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
|
}
|
|
|
|
if (file.size > MAX_BYTES) {
|
|
return NextResponse.json({ error: "File too large (max 8MB)" }, { status: 400 });
|
|
}
|
|
|
|
const ext = file.name.split(".").pop()?.toLowerCase() || "jpg";
|
|
const id = randomUUID();
|
|
const imageKey = `garments/${familyId}/${id}-original.${ext}`;
|
|
const thumbKey = `garments/${familyId}/${id}-thumb.webp`;
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const originalBuffer = Buffer.from(arrayBuffer);
|
|
|
|
const thumbBuffer = await sharp(originalBuffer)
|
|
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
|
.webp({ quality: 70 })
|
|
.toBuffer();
|
|
|
|
const client = makeClient(R2);
|
|
|
|
await Promise.all([
|
|
client.send(new PutObjectCommand({
|
|
Bucket: R2.bucket,
|
|
Key: imageKey,
|
|
Body: originalBuffer,
|
|
ContentType: file.type,
|
|
})),
|
|
client.send(new PutObjectCommand({
|
|
Bucket: R2.bucket,
|
|
Key: thumbKey,
|
|
Body: thumbBuffer,
|
|
ContentType: "image/webp",
|
|
})),
|
|
]);
|
|
|
|
const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`;
|
|
|
|
return NextResponse.json({
|
|
imageKey,
|
|
thumbKey,
|
|
imageUrl: `${baseUrl}/${imageKey}`,
|
|
thumbUrl: `${baseUrl}/${thumbKey}`,
|
|
});
|
|
}
|