tia/src/app/api/garments/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

160 lines
5.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
// GET /api/garments?childId=&status=active&category=&sizeLabel=&season=
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
const status = searchParams.get("status") || "active";
const category = searchParams.get("category");
const sizeLabel = searchParams.get("sizeLabel");
const season = searchParams.get("season");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
// Build filter conditions using raw SQL for array contains
let rows;
if (category && sizeLabel && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND size_label = ${sizeLabel}
AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (category && sizeLabel) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND size_label = ${sizeLabel}
ORDER BY created_at DESC`;
} else if (category && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (sizeLabel && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND size_label = ${sizeLabel} AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (category) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category}
ORDER BY created_at DESC`;
} else if (sizeLabel) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND size_label = ${sizeLabel}
ORDER BY created_at DESC`;
} else if (season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
ORDER BY created_at DESC`;
}
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({
success: true,
items: rows.map(g => toDto(g, baseUrl)),
});
}
// POST /api/garments
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: {
childId?: string;
name?: string;
category?: string;
sizeLabel?: string;
colors?: string[];
seasons?: string[];
occasionTags?: string[];
imageKey?: string;
thumbKey?: string;
status?: string;
acquiredVia?: string;
giftFrom?: string;
visionMetadata?: unknown;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { childId, name, category, sizeLabel, colors = [], seasons = [],
occasionTags = [], imageKey, thumbKey, acquiredVia, giftFrom, visionMetadata } = body;
if (!childId || !category || !sizeLabel || !imageKey || !thumbKey) {
return NextResponse.json({ error: "childId, category, sizeLabel, imageKey, thumbKey required" }, { status: 400 });
}
if (!GARMENT_CATEGORIES.includes(category as never)) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}
// Verify child belongs to family
const childCheck = await sql`SELECT id FROM children WHERE id = ${childId} AND family_id = ${familyId} LIMIT 1`;
if (!childCheck[0]) return NextResponse.json({ error: "Child not found" }, { status: 404 });
const rows = await sql`
INSERT INTO garments
(family_id, child_id, name, category, size_label, colors, seasons,
occasion_tags, image_key, thumb_key, status, acquired_via, gift_from, vision_metadata)
VALUES
(${familyId}, ${childId}, ${name || null}, ${category}, ${sizeLabel},
${colors}, ${seasons}, ${occasionTags}, ${imageKey}, ${thumbKey},
'active', ${acquiredVia || null}, ${giftFrom || null},
${visionMetadata ? JSON.stringify(visionMetadata) : null})
RETURNING *`;
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 });
}
function toDto(g: Record<string, unknown>, baseUrl: string) {
return {
id: g.id,
familyId: g.family_id,
childId: g.child_id,
name: g.name,
category: g.category,
sizeLabel: g.size_label,
colors: g.colors || [],
seasons: g.seasons || [],
occasionTags: g.occasion_tags || [],
imageKey: g.image_key,
thumbKey: g.thumb_key,
imageUrl: `${baseUrl}/${g.image_key}`,
thumbUrl: `${baseUrl}/${g.thumb_key}`,
status: g.status,
acquiredVia: g.acquired_via,
giftFrom: g.gift_from,
createdAt: g.created_at,
updatedAt: g.updated_at,
};
}