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>
160 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|