tia/src/app/api/garments/upload/route.ts
Mannu 1994725101 feat(wardrobe): add complete wardrobe feature (W0–W9)
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>
2026-05-23 18:09:22 +05:30

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