From 1994725101d0d68980daf1b9db25badc261ed2cc Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 23 May 2026 18:09:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(wardrobe):=20add=20complete=20wardrobe=20f?= =?UTF-8?q?eature=20(W0=E2=80=93W9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drizzle/0001_wardrobe_tables.sql | 39 + drizzle/0002_outfits_table.sql | 14 + drizzle/manual/06-wardrobe-rls.sql | 21 + drizzle/meta/0001_snapshot.json | 3215 +++++++++++++++++++ drizzle/meta/0002_snapshot.json | 3332 ++++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/app/api/garments/[id]/route.ts | 110 + src/app/api/garments/[id]/wear/route.ts | 35 + src/app/api/garments/outfit/route.ts | 72 + src/app/api/garments/outfits/[id]/route.ts | 17 + src/app/api/garments/outfits/route.ts | 51 + src/app/api/garments/outgrowth/route.ts | 66 + src/app/api/garments/packing/route.ts | 63 + src/app/api/garments/route.ts | 160 + src/app/api/garments/tag/route.ts | 31 + src/app/api/garments/upload/route.ts | 99 + src/app/menu/page.tsx | 1 + src/app/wardrobe/[id]/page.tsx | 411 +++ src/app/wardrobe/add/page.tsx | 467 +++ src/app/wardrobe/outfit/page.tsx | 198 ++ src/app/wardrobe/packing/page.tsx | 164 + src/app/wardrobe/page.tsx | 248 ++ src/app/wardrobe/saved-outfits/page.tsx | 146 + src/db/schema/index.ts | 1 + src/db/schema/wardrobe.ts | 131 + src/lib/wardrobe/outfit.ts | 74 + src/lib/wardrobe/vision.ts | 152 + src/lib/wardrobe/weather.ts | 43 + 28 files changed, 9375 insertions(+) create mode 100644 drizzle/0001_wardrobe_tables.sql create mode 100644 drizzle/0002_outfits_table.sql create mode 100644 drizzle/manual/06-wardrobe-rls.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/app/api/garments/[id]/route.ts create mode 100644 src/app/api/garments/[id]/wear/route.ts create mode 100644 src/app/api/garments/outfit/route.ts create mode 100644 src/app/api/garments/outfits/[id]/route.ts create mode 100644 src/app/api/garments/outfits/route.ts create mode 100644 src/app/api/garments/outgrowth/route.ts create mode 100644 src/app/api/garments/packing/route.ts create mode 100644 src/app/api/garments/route.ts create mode 100644 src/app/api/garments/tag/route.ts create mode 100644 src/app/api/garments/upload/route.ts create mode 100644 src/app/wardrobe/[id]/page.tsx create mode 100644 src/app/wardrobe/add/page.tsx create mode 100644 src/app/wardrobe/outfit/page.tsx create mode 100644 src/app/wardrobe/packing/page.tsx create mode 100644 src/app/wardrobe/page.tsx create mode 100644 src/app/wardrobe/saved-outfits/page.tsx create mode 100644 src/db/schema/wardrobe.ts create mode 100644 src/lib/wardrobe/outfit.ts create mode 100644 src/lib/wardrobe/vision.ts create mode 100644 src/lib/wardrobe/weather.ts diff --git a/drizzle/0001_wardrobe_tables.sql b/drizzle/0001_wardrobe_tables.sql new file mode 100644 index 0000000..2f2267d --- /dev/null +++ b/drizzle/0001_wardrobe_tables.sql @@ -0,0 +1,39 @@ +CREATE TABLE "garment_wears" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "family_id" uuid NOT NULL, + "garment_id" uuid NOT NULL, + "worn_on" date NOT NULL, + "memory_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "garments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "family_id" uuid NOT NULL, + "child_id" uuid NOT NULL, + "name" text, + "category" text NOT NULL, + "size_label" text NOT NULL, + "colors" text[] DEFAULT '{}', + "seasons" text[] DEFAULT '{}', + "occasion_tags" text[] DEFAULT '{}', + "image_key" text NOT NULL, + "thumb_key" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "acquired_via" text, + "gift_from" text, + "vision_metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_garment_id_garments_id_fk" FOREIGN KEY ("garment_id") REFERENCES "public"."garments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_memory_id_memories_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "garments" ADD CONSTRAINT "garments_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "garments" ADD CONSTRAINT "garments_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "garment_wears_garment_idx" ON "garment_wears" USING btree ("garment_id");--> statement-breakpoint +CREATE INDEX "garment_wears_family_idx" ON "garment_wears" USING btree ("family_id");--> statement-breakpoint +CREATE INDEX "garments_family_idx" ON "garments" USING btree ("family_id");--> statement-breakpoint +CREATE INDEX "garments_child_idx" ON "garments" USING btree ("child_id");--> statement-breakpoint +CREATE INDEX "garments_status_idx" ON "garments" USING btree ("status"); \ No newline at end of file diff --git a/drizzle/0002_outfits_table.sql b/drizzle/0002_outfits_table.sql new file mode 100644 index 0000000..19a2b84 --- /dev/null +++ b/drizzle/0002_outfits_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE "outfits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "family_id" uuid NOT NULL, + "child_id" uuid NOT NULL, + "name" text NOT NULL, + "garment_ids" uuid[] DEFAULT '{}' NOT NULL, + "occasion_tags" text[] DEFAULT '{}', + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "outfits" ADD CONSTRAINT "outfits_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "outfits" ADD CONSTRAINT "outfits_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "outfits_family_idx" ON "outfits" USING btree ("family_id");--> statement-breakpoint +CREATE INDEX "outfits_child_idx" ON "outfits" USING btree ("child_id"); \ No newline at end of file diff --git a/drizzle/manual/06-wardrobe-rls.sql b/drizzle/manual/06-wardrobe-rls.sql new file mode 100644 index 0000000..85ca3c5 --- /dev/null +++ b/drizzle/manual/06-wardrobe-rls.sql @@ -0,0 +1,21 @@ +-- RLS for garments and garment_wears +-- Run AFTER 0001_wardrobe_tables.sql has been applied +-- Apply as superuser: psql $DATABASE_URL_SUPERUSER -f drizzle/manual/06-wardrobe-rls.sql + +ALTER TABLE garments ENABLE ROW LEVEL SECURITY; +ALTER TABLE garment_wears ENABLE ROW LEVEL SECURITY; + +-- Both tables carry family_id directly, so we use the same direct comparison +-- pattern as family_invites rather than the child_id subquery pattern. +-- FOR ALL with USING also enforces WITH CHECK on INSERT (prevents cross-family writes). +CREATE POLICY family_isolation ON garments + FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid); + +CREATE POLICY family_isolation ON garment_wears + FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid); + +-- W9: Saved outfits +ALTER TABLE outfits ENABLE ROW LEVEL SECURITY; + +CREATE POLICY family_isolation ON outfits + FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..8a00cde --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,3215 @@ +{ + "id": "204db33d-42d0-4f86-9aaf-818bebec5e10", + "prevId": "d7f9a8ea-f8b4-42f7-89b1-5c24c822b28f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_sessions": { + "name": "admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_sessions_session_token_unique": { + "name": "admin_sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admins": { + "name": "admins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admins_username_unique": { + "name": "admins_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": { + "admins_role_check": { + "name": "admins_role_check", + "value": "(role)::text = ANY (ARRAY['super_admin','admin','support'])" + } + }, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_resets_token_unique": { + "name": "password_resets_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member_profiles": { + "name": "member_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_profiles_slug_idx": { + "name": "member_profiles_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member_profiles_user_id_unique": { + "name": "member_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_clicks": { + "name": "product_clicks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "product_clicks_product_idx": { + "name": "product_clicks_product_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "clicked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommended_products": { + "name": "recommended_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recommended_products_profile_idx": { + "name": "recommended_products_profile_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage": { + "name": "ai_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_estimate_paise": { + "name": "cost_estimate_paise", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_created_idx": { + "name": "ai_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_family_idx": { + "name": "ai_usage_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_family_idx": { + "name": "audit_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_action": { + "name": "idx_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created": { + "name": "idx_audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_family": { + "name": "idx_audit_log_family", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_user": { + "name": "idx_audit_log_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_corrections": { + "name": "log_corrections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dose_id": { + "name": "dose_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_value": { + "name": "original_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "corrected_value": { + "name": "corrected_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "corrected_by": { + "name": "corrected_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_corrections_dose_idx": { + "name": "log_corrections_dose_idx", + "columns": [ + { + "expression": "dose_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_idx": { + "name": "accounts_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_updated_at": { + "name": "password_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_tokens_idx": { + "name": "verification_tokens_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.children": { + "name": "children", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "child_sex", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "child_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'newborn'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "children_family_idx": { + "name": "children_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.families": { + "name": "families", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'free'" + }, + "max_children": { + "name": "max_children", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "pediatrician_phone": { + "name": "pediatrician_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.family_invites": { + "name": "family_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_idx": { + "name": "invite_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "family_invites_token_unique": { + "name": "family_invites_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.family_members": { + "name": "family_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'caregiver'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "family_members_family_id_user_id_key": { + "name": "family_members_family_id_user_id_key", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_thumbnail_key": { + "name": "r2_thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_family_idx": { + "name": "attachments_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memories": { + "name": "memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_thumbnail_key": { + "name": "r2_thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vision_caption": { + "name": "vision_caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_tags": { + "name": "vision_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "vision_embedding": { + "name": "vision_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memories_family_idx": { + "name": "memories_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_child_idx": { + "name": "memories_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_embedding_idx": { + "name": "memories_embedding_idx", + "columns": [ + { + "expression": "vision_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diapers_logs": { + "name": "diapers_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "diaper_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "diapers_logs_child_id_children_id_fk": { + "name": "diapers_logs_child_id_children_id_fk", + "tableFrom": "diapers_logs", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "feed_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "amount_ml": { + "name": "amount_ml", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feeds_child_id_children_id_fk": { + "name": "feeds_child_id_children_id_fk", + "tableFrom": "feeds", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.growth": { + "name": "growth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "measured_at": { + "name": "measured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "weight_kg": { + "name": "weight_kg", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_cm": { + "name": "height_cm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "head_circumference_cm": { + "name": "head_circumference_cm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "growth_child_id_children_id_fk": { + "name": "growth_child_id_children_id_fk", + "tableFrom": "growth", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medications": { + "name": "medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dosage": { + "name": "dosage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_child_id_children_id_fk": { + "name": "medications_child_id_children_id_fk", + "tableFrom": "medications", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.milestone_achievements": { + "name": "milestone_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "milestone_key": { + "name": "milestone_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achieved_at": { + "name": "achieved_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "milestone_child_idx": { + "name": "milestone_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "milestone_achievements_child_milestone_unique": { + "name": "milestone_achievements_child_milestone_unique", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "milestone_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sleeps": { + "name": "sleeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "sleep_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sleeps_child_id_children_id_fk": { + "name": "sleeps_child_id_children_id_fk", + "tableFrom": "sleeps", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vaccinations": { + "name": "vaccinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vaccine_name": { + "name": "vaccine_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "given_date": { + "name": "given_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lot_number": { + "name": "lot_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "vaccinations_child_id_children_id_fk": { + "name": "vaccinations_child_id_children_id_fk", + "tableFrom": "vaccinations", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allergies": { + "name": "allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'mild'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.doctor_visits": { + "name": "doctor_visits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "doctor_name": { + "name": "doctor_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "visit_date": { + "name": "visit_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.illness_logs": { + "name": "illness_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medication_doses": { + "name": "medication_doses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "medicine_id": { + "name": "medicine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "administered_at": { + "name": "administered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "administered_by": { + "name": "administered_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount_given": { + "name": "amount_given", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "medication_doses_family_idx": { + "name": "medication_doses_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "medication_doses_medicine_idx": { + "name": "medication_doses_medicine_idx", + "columns": [ + { + "expression": "medicine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medicines": { + "name": "medicines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminder_time": { + "name": "reminder_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_responses": { + "name": "support_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'normal'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_tickets_status_check": { + "name": "support_tickets_status_check", + "value": "(status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])" + }, + "support_tickets_priority_check": { + "name": "support_tickets_priority_check", + "value": "(priority)::text = ANY (ARRAY['low','normal','high','urgent'])" + } + }, + "isRLSEnabled": false + }, + "public.garment_wears": { + "name": "garment_wears", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "garment_id": { + "name": "garment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "worn_on": { + "name": "worn_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "garment_wears_garment_idx": { + "name": "garment_wears_garment_idx", + "columns": [ + { + "expression": "garment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garment_wears_family_idx": { + "name": "garment_wears_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "garment_wears_family_id_families_id_fk": { + "name": "garment_wears_family_id_families_id_fk", + "tableFrom": "garment_wears", + "tableTo": "families", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "garment_wears_garment_id_garments_id_fk": { + "name": "garment_wears_garment_id_garments_id_fk", + "tableFrom": "garment_wears", + "tableTo": "garments", + "columnsFrom": [ + "garment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "garment_wears_memory_id_memories_id_fk": { + "name": "garment_wears_memory_id_memories_id_fk", + "tableFrom": "garment_wears", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.garments": { + "name": "garments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_label": { + "name": "size_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "seasons": { + "name": "seasons", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "occasion_tags": { + "name": "occasion_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumb_key": { + "name": "thumb_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "acquired_via": { + "name": "acquired_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gift_from": { + "name": "gift_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_metadata": { + "name": "vision_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "garments_family_idx": { + "name": "garments_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garments_child_idx": { + "name": "garments_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garments_status_idx": { + "name": "garments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "garments_family_id_families_id_fk": { + "name": "garments_family_id_families_id_fk", + "tableFrom": "garments", + "tableTo": "families", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "garments_child_id_children_id_fk": { + "name": "garments_child_id_children_id_fk", + "tableFrom": "garments", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.child_sex": { + "name": "child_sex", + "schema": "public", + "values": [ + "male", + "female", + "other" + ] + }, + "public.child_stage": { + "name": "child_stage", + "schema": "public", + "values": [ + "newborn", + "infant", + "solids_start", + "toddler_early", + "toddler_late", + "preschool" + ] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "admin", + "caregiver", + "viewer" + ] + }, + "public.diaper_type": { + "name": "diaper_type", + "schema": "public", + "values": [ + "wet", + "dirty", + "both", + "dry" + ] + }, + "public.feed_method": { + "name": "feed_method", + "schema": "public", + "values": [ + "bottle", + "breast_left", + "breast_right", + "breast_both", + "cup", + "spoon", + "finger", + "self" + ] + }, + "public.feed_type": { + "name": "feed_type", + "schema": "public", + "values": [ + "breast_milk", + "formula", + "solid", + "water", + "other" + ] + }, + "public.sleep_type": { + "name": "sleep_type", + "schema": "public", + "values": [ + "nap", + "night" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..0831ab0 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,3332 @@ +{ + "id": "d5418166-fcb8-47de-b525-9ef2fc1a68e1", + "prevId": "204db33d-42d0-4f86-9aaf-818bebec5e10", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_sessions": { + "name": "admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_sessions_session_token_unique": { + "name": "admin_sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admins": { + "name": "admins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admins_username_unique": { + "name": "admins_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": { + "admins_role_check": { + "name": "admins_role_check", + "value": "(role)::text = ANY (ARRAY['super_admin','admin','support'])" + } + }, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_resets_token_unique": { + "name": "password_resets_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member_profiles": { + "name": "member_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_profiles_slug_idx": { + "name": "member_profiles_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member_profiles_user_id_unique": { + "name": "member_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_clicks": { + "name": "product_clicks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "product_clicks_product_idx": { + "name": "product_clicks_product_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "clicked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommended_products": { + "name": "recommended_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recommended_products_profile_idx": { + "name": "recommended_products_profile_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage": { + "name": "ai_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_estimate_paise": { + "name": "cost_estimate_paise", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_created_idx": { + "name": "ai_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_family_idx": { + "name": "ai_usage_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_family_idx": { + "name": "audit_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_action": { + "name": "idx_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created": { + "name": "idx_audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_family": { + "name": "idx_audit_log_family", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_user": { + "name": "idx_audit_log_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_corrections": { + "name": "log_corrections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dose_id": { + "name": "dose_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "original_value": { + "name": "original_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "corrected_value": { + "name": "corrected_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "corrected_by": { + "name": "corrected_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_corrections_dose_idx": { + "name": "log_corrections_dose_idx", + "columns": [ + { + "expression": "dose_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_idx": { + "name": "accounts_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_updated_at": { + "name": "password_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_tokens_idx": { + "name": "verification_tokens_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.children": { + "name": "children", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "child_sex", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "child_stage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'newborn'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "children_family_idx": { + "name": "children_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.families": { + "name": "families", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'free'" + }, + "max_children": { + "name": "max_children", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "pediatrician_phone": { + "name": "pediatrician_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.family_invites": { + "name": "family_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_idx": { + "name": "invite_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "family_invites_token_unique": { + "name": "family_invites_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.family_members": { + "name": "family_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'caregiver'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "family_members_family_id_user_id_key": { + "name": "family_members_family_id_user_id_key", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_thumbnail_key": { + "name": "r2_thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_family_idx": { + "name": "attachments_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memories": { + "name": "memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_thumbnail_key": { + "name": "r2_thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vision_caption": { + "name": "vision_caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_tags": { + "name": "vision_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "vision_embedding": { + "name": "vision_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memories_family_idx": { + "name": "memories_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_child_idx": { + "name": "memories_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memories_embedding_idx": { + "name": "memories_embedding_idx", + "columns": [ + { + "expression": "vision_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diapers_logs": { + "name": "diapers_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "diaper_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "diapers_logs_child_id_children_id_fk": { + "name": "diapers_logs_child_id_children_id_fk", + "tableFrom": "diapers_logs", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "feed_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "amount_ml": { + "name": "amount_ml", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feeds_child_id_children_id_fk": { + "name": "feeds_child_id_children_id_fk", + "tableFrom": "feeds", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.growth": { + "name": "growth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "measured_at": { + "name": "measured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "weight_kg": { + "name": "weight_kg", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "height_cm": { + "name": "height_cm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "head_circumference_cm": { + "name": "head_circumference_cm", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "growth_child_id_children_id_fk": { + "name": "growth_child_id_children_id_fk", + "tableFrom": "growth", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medications": { + "name": "medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dosage": { + "name": "dosage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "medications_child_id_children_id_fk": { + "name": "medications_child_id_children_id_fk", + "tableFrom": "medications", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.milestone_achievements": { + "name": "milestone_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "milestone_key": { + "name": "milestone_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achieved_at": { + "name": "achieved_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "milestone_child_idx": { + "name": "milestone_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "milestone_achievements_child_milestone_unique": { + "name": "milestone_achievements_child_milestone_unique", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "milestone_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sleeps": { + "name": "sleeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "sleep_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sleeps_child_id_children_id_fk": { + "name": "sleeps_child_id_children_id_fk", + "tableFrom": "sleeps", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vaccinations": { + "name": "vaccinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vaccine_name": { + "name": "vaccine_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "given_date": { + "name": "given_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lot_number": { + "name": "lot_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "vaccinations_child_id_children_id_fk": { + "name": "vaccinations_child_id_children_id_fk", + "tableFrom": "vaccinations", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allergies": { + "name": "allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'mild'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.doctor_visits": { + "name": "doctor_visits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "doctor_name": { + "name": "doctor_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "visit_date": { + "name": "visit_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.illness_logs": { + "name": "illness_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medication_doses": { + "name": "medication_doses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "medicine_id": { + "name": "medicine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "administered_at": { + "name": "administered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "administered_by": { + "name": "administered_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount_given": { + "name": "amount_given", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "medication_doses_family_idx": { + "name": "medication_doses_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "medication_doses_medicine_idx": { + "name": "medication_doses_medicine_idx", + "columns": [ + { + "expression": "medicine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.medicines": { + "name": "medicines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminder_time": { + "name": "reminder_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_responses": { + "name": "support_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'normal'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_tickets_status_check": { + "name": "support_tickets_status_check", + "value": "(status)::text = ANY (ARRAY['open','in_progress','resolved','closed'])" + }, + "support_tickets_priority_check": { + "name": "support_tickets_priority_check", + "value": "(priority)::text = ANY (ARRAY['low','normal','high','urgent'])" + } + }, + "isRLSEnabled": false + }, + "public.garment_wears": { + "name": "garment_wears", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "garment_id": { + "name": "garment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "worn_on": { + "name": "worn_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "garment_wears_garment_idx": { + "name": "garment_wears_garment_idx", + "columns": [ + { + "expression": "garment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garment_wears_family_idx": { + "name": "garment_wears_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "garment_wears_family_id_families_id_fk": { + "name": "garment_wears_family_id_families_id_fk", + "tableFrom": "garment_wears", + "tableTo": "families", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "garment_wears_garment_id_garments_id_fk": { + "name": "garment_wears_garment_id_garments_id_fk", + "tableFrom": "garment_wears", + "tableTo": "garments", + "columnsFrom": [ + "garment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "garment_wears_memory_id_memories_id_fk": { + "name": "garment_wears_memory_id_memories_id_fk", + "tableFrom": "garment_wears", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.garments": { + "name": "garments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_label": { + "name": "size_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "seasons": { + "name": "seasons", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "occasion_tags": { + "name": "occasion_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumb_key": { + "name": "thumb_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "acquired_via": { + "name": "acquired_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gift_from": { + "name": "gift_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_metadata": { + "name": "vision_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "garments_family_idx": { + "name": "garments_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garments_child_idx": { + "name": "garments_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "garments_status_idx": { + "name": "garments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "garments_family_id_families_id_fk": { + "name": "garments_family_id_families_id_fk", + "tableFrom": "garments", + "tableTo": "families", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "garments_child_id_children_id_fk": { + "name": "garments_child_id_children_id_fk", + "tableFrom": "garments", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outfits": { + "name": "outfits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "child_id": { + "name": "child_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "garment_ids": { + "name": "garment_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "occasion_tags": { + "name": "occasion_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outfits_family_idx": { + "name": "outfits_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outfits_child_idx": { + "name": "outfits_child_idx", + "columns": [ + { + "expression": "child_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outfits_family_id_families_id_fk": { + "name": "outfits_family_id_families_id_fk", + "tableFrom": "outfits", + "tableTo": "families", + "columnsFrom": [ + "family_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "outfits_child_id_children_id_fk": { + "name": "outfits_child_id_children_id_fk", + "tableFrom": "outfits", + "tableTo": "children", + "columnsFrom": [ + "child_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.child_sex": { + "name": "child_sex", + "schema": "public", + "values": [ + "male", + "female", + "other" + ] + }, + "public.child_stage": { + "name": "child_stage", + "schema": "public", + "values": [ + "newborn", + "infant", + "solids_start", + "toddler_early", + "toddler_late", + "preschool" + ] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "admin", + "caregiver", + "viewer" + ] + }, + "public.diaper_type": { + "name": "diaper_type", + "schema": "public", + "values": [ + "wet", + "dirty", + "both", + "dry" + ] + }, + "public.feed_method": { + "name": "feed_method", + "schema": "public", + "values": [ + "bottle", + "breast_left", + "breast_right", + "breast_both", + "cup", + "spoon", + "finger", + "self" + ] + }, + "public.feed_type": { + "name": "feed_type", + "schema": "public", + "values": [ + "breast_milk", + "formula", + "solid", + "water", + "other" + ] + }, + "public.sleep_type": { + "name": "sleep_type", + "schema": "public", + "values": [ + "nap", + "night" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 71d695e..3cf6f92 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1779518962214, "tag": "0000_baseline_prod_2026_05_19", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779538936553, + "tag": "0001_wardrobe_tables", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779539431897, + "tag": "0002_outfits_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/garments/[id]/route.ts b/src/app/api/garments/[id]/route.ts new file mode 100644 index 0000000..a5a2d97 --- /dev/null +++ b/src/app/api/garments/[id]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; +import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe"; + +function toDto(g: Record, 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, + visionMetadata: g.vision_metadata, + createdAt: g.created_at, + updatedAt: g.updated_at, + }; +} + +function getBaseUrl() { + return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; +} + +// GET /api/garments/[id] +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + const rows = await sql`SELECT * FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`; + if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const wears = await sql` + SELECT id, worn_on, memory_id, created_at FROM garment_wears + WHERE garment_id = ${id} AND family_id = ${familyId} + ORDER BY worn_on DESC`; + + return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears }); +} + +// PATCH /api/garments/[id] +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + const existing = await sql`SELECT id FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`; + if (!existing[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const VALID_STATUSES = ["active", "stored", "outgrown", "donated"]; + if (body.status && !VALID_STATUSES.includes(body.status as string)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + if (body.category && !GARMENT_CATEGORIES.includes(body.category as never)) { + return NextResponse.json({ error: "Invalid category" }, { status: 400 }); + } + + const rows = await sql` + UPDATE garments SET + name = COALESCE(${body.name as string | null ?? null}, name), + category = COALESCE(${body.category as string | null ?? null}, category), + size_label = COALESCE(${body.sizeLabel as string | null ?? null}, size_label), + colors = COALESCE(${body.colors as string[] | null ?? null}, colors), + seasons = COALESCE(${body.seasons as string[] | null ?? null}, seasons), + occasion_tags = COALESCE(${body.occasionTags as string[] | null ?? null}, occasion_tags), + status = COALESCE(${body.status as string | null ?? null}, status), + acquired_via = COALESCE(${body.acquiredVia as string | null ?? null}, acquired_via), + gift_from = COALESCE(${body.giftFrom as string | null ?? null}, gift_from), + vision_metadata = COALESCE(${body.visionMetadata ? JSON.stringify(body.visionMetadata) : null}, vision_metadata), + updated_at = now() + WHERE id = ${id} AND family_id = ${familyId} + RETURNING *`; + + return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) }); +} + +// DELETE /api/garments/[id] +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + const rows = await sql`DELETE FROM garments WHERE id = ${id} AND family_id = ${familyId} RETURNING id`; + if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/garments/[id]/wear/route.ts b/src/app/api/garments/[id]/wear/route.ts new file mode 100644 index 0000000..4ced6aa --- /dev/null +++ b/src/app/api/garments/[id]/wear/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; + +// POST /api/garments/[id]/wear +// Logs that a garment was worn today (or a specific date) +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + // Confirm garment belongs to family + const garment = await sql`SELECT id FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`; + if (!garment[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + let wornOn: string = new Date().toISOString().slice(0, 10); // default today + let memoryId: string | null = null; + + try { + const body = await req.json(); + if (body.wornOn) wornOn = body.wornOn; + if (body.memoryId) memoryId = body.memoryId; + } catch { + // body is optional + } + + const rows = await sql` + INSERT INTO garment_wears (family_id, garment_id, worn_on, memory_id) + VALUES (${familyId}, ${id}, ${wornOn}, ${memoryId}) + RETURNING *`; + + return NextResponse.json({ success: true, wear: rows[0] }, { status: 201 }); +} diff --git a/src/app/api/garments/outfit/route.ts b/src/app/api/garments/outfit/route.ts new file mode 100644 index 0000000..9d715f4 --- /dev/null +++ b/src/app/api/garments/outfit/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; +import { getGurgaonWeather } from "@/lib/wardrobe/weather"; +import { buildOutfits, type GarmentRow } from "@/lib/wardrobe/outfit"; + +// GET /api/garments/outfit?childId=&occasion= +// Returns deterministic outfit suggestions based on today's weather. +// No LLM — pure filter + rule logic. +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 occasion = searchParams.get("occasion"); + + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + const weather = await getGurgaonWeather(); + + let rows; + if (occasion) { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + AND ${weather.season} = ANY(seasons) + AND ${occasion} = ANY(occasion_tags) + AND category IN ('top','bottom','dress','onesie') + ORDER BY RANDOM()`; + } else { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + AND ${weather.season} = ANY(seasons) + AND category IN ('top','bottom','dress','onesie') + ORDER BY RANDOM()`; + } + + const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; + + const garments: GarmentRow[] = rows.map(g => ({ + id: g.id as string, + name: g.name as string | null, + category: g.category as string, + sizeLabel: g.size_label as string, + colors: (g.colors as string[]) || [], + seasons: (g.seasons as string[]) || [], + occasionTags: (g.occasion_tags as string[]) || [], + thumbKey: g.thumb_key as string, + imageKey: g.image_key as string, + })); + + const outfits = buildOutfits(garments, 3); + + const outfitsWithUrls = outfits.map(o => ({ + ...o, + items: o.items.map(item => ({ + ...item, + thumbUrl: `${baseUrl}/${item.thumbKey}`, + imageUrl: `${baseUrl}/${item.imageKey}`, + })), + })); + + return NextResponse.json({ + success: true, + weather, + weatherBasis: `${weather.description} — ${Math.round(weather.tempC)}°C today in Gurugram`, + outfits: outfitsWithUrls, + }); +} diff --git a/src/app/api/garments/outfits/[id]/route.ts b/src/app/api/garments/outfits/[id]/route.ts new file mode 100644 index 0000000..01bd009 --- /dev/null +++ b/src/app/api/garments/outfits/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; + +// DELETE /api/garments/outfits/[id] +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + const rows = await sql`DELETE FROM outfits WHERE id = ${id} AND family_id = ${familyId} RETURNING id`; + if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/garments/outfits/route.ts b/src/app/api/garments/outfits/route.ts new file mode 100644 index 0000000..bda45a9 --- /dev/null +++ b/src/app/api/garments/outfits/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; + +// GET /api/garments/outfits?childId= +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"); + + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + const rows = await sql` + SELECT * FROM outfits + WHERE family_id = ${familyId} AND child_id = ${childId} + ORDER BY created_at DESC`; + + return NextResponse.json({ success: true, items: rows }); +} + +// POST /api/garments/outfits +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; garmentIds?: string[]; occasionTags?: string[] }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { childId, name, garmentIds = [], occasionTags = [] } = body; + if (!childId || !name) return NextResponse.json({ error: "childId and name required" }, { 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 outfits (family_id, child_id, name, garment_ids, occasion_tags) + VALUES (${familyId}, ${childId}, ${name}, ${garmentIds}, ${occasionTags}) + RETURNING *`; + + return NextResponse.json({ success: true, item: rows[0] }, { status: 201 }); +} diff --git a/src/app/api/garments/outgrowth/route.ts b/src/app/api/garments/outgrowth/route.ts new file mode 100644 index 0000000..618fede --- /dev/null +++ b/src/app/api/garments/outgrowth/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; +import { GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; + +// GET /api/garments/outgrowth?childId= +// Returns active garments that are likely outgrown: +// - size_label sorts BELOW the most recently-added active garment's size_label +// - AND no garment_wears in the last 45 days +// +// Size ordering uses an explicit ordered list to avoid lexicographic errors +// (e.g. '9-12m' < '12-18m' by size but '9' > '1' lexicographically). +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"); + + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + // Fetch all active garments for this child + const active = await sql` + SELECT g.id, g.name, g.category, g.size_label, g.thumb_key, g.created_at, + MAX(gw.worn_on) AS last_worn + FROM garments g + LEFT JOIN garment_wears gw ON gw.garment_id = g.id + WHERE g.family_id = ${familyId} AND g.child_id = ${childId} AND g.status = 'active' + GROUP BY g.id + ORDER BY g.created_at DESC`; + + if (active.length === 0) { + return NextResponse.json({ success: true, candidates: [], currentSizeLabel: null }); + } + + // Find the most recently-added active garment's size_label + const currentSizeLabel = active[0].size_label as string; + const currentSizeIdx = GARMENT_SIZE_ORDER.indexOf(currentSizeLabel as never); + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 45); + + const candidates = active.filter(g => { + const sizeIdx = GARMENT_SIZE_ORDER.indexOf(g.size_label as never); + const isSmaller = sizeIdx >= 0 && currentSizeIdx >= 0 && sizeIdx < currentSizeIdx; + const lastWorn = g.last_worn ? new Date(g.last_worn as string) : null; + const notWornRecently = !lastWorn || lastWorn < cutoff; + return isSmaller && notWornRecently; + }); + + const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; + + return NextResponse.json({ + success: true, + currentSizeLabel, + candidates: candidates.map(g => ({ + id: g.id, + name: g.name, + category: g.category, + sizeLabel: g.size_label, + thumbUrl: `${baseUrl}/${g.thumb_key}`, + lastWorn: g.last_worn, + })), + }); +} diff --git a/src/app/api/garments/packing/route.ts b/src/app/api/garments/packing/route.ts new file mode 100644 index 0000000..506a7c0 --- /dev/null +++ b/src/app/api/garments/packing/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { sql } from "@/db"; + +// GET /api/garments/packing?childId=&season=&occasion=&days=3 +// Returns active garments matching size + season + occasion, grouped by category. +// Pure SQL — no AI. +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 season = searchParams.get("season"); + const occasion = searchParams.get("occasion"); + + if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 }); + + let rows; + if (season && occasion) { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + AND ${season} = ANY(seasons) AND ${occasion} = ANY(occasion_tags) + ORDER BY category, name`; + } else if (season) { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + AND ${season} = ANY(seasons) + ORDER BY category, name`; + } else if (occasion) { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + AND ${occasion} = ANY(occasion_tags) + ORDER BY category, name`; + } else { + rows = await sql` + SELECT * FROM garments + WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active' + ORDER BY category, name`; + } + + const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`; + + // Group by category + const grouped: Record = {}; + for (const g of rows) { + const cat = g.category as string; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push({ + id: g.id as string, + name: (g.name as string) || cat, + sizeLabel: g.size_label as string, + thumbUrl: `${baseUrl}/${g.thumb_key}`, + checked: false, + }); + } + + return NextResponse.json({ success: true, groups: grouped, total: rows.length }); +} diff --git a/src/app/api/garments/route.ts b/src/app/api/garments/route.ts new file mode 100644 index 0000000..b0e141a --- /dev/null +++ b/src/app/api/garments/route.ts @@ -0,0 +1,160 @@ +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, 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, + }; +} diff --git a/src/app/api/garments/tag/route.ts b/src/app/api/garments/tag/route.ts new file mode 100644 index 0000000..5d19a3c --- /dev/null +++ b/src/app/api/garments/tag/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { tagGarment } from "@/lib/wardrobe/vision"; + +// POST /api/garments/tag +// Body: { imageKey: string } +// Returns vision-tagged garment metadata +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: { imageKey?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { imageKey } = body; + if (!imageKey) return NextResponse.json({ error: "imageKey required" }, { status: 400 }); + + // Verify the key belongs to this family (key contains family_id by construction) + if (!imageKey.startsWith(`garments/${familyId}/`)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const result = await tagGarment(imageKey); + return NextResponse.json({ success: true, ...result }); +} diff --git a/src/app/api/garments/upload/route.ts b/src/app/api/garments/upload/route.ts new file mode 100644 index 0000000..7236296 --- /dev/null +++ b/src/app/api/garments/upload/route.ts @@ -0,0 +1,99 @@ +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) { + 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}`, + }); +} diff --git a/src/app/menu/page.tsx b/src/app/menu/page.tsx index ad4b84d..7719bfd 100644 --- a/src/app/menu/page.tsx +++ b/src/app/menu/page.tsx @@ -16,6 +16,7 @@ export default function MenuPage() { { icon: "📸", label: "Memories", href: "/memories" }, { icon: "🤖", label: "AI Chat", href: "/ai" }, { icon: "🌟", label: "Milestones", href: "/milestones" }, + { icon: "👗", label: "Wardrobe", href: "/wardrobe" }, ]; const handleSignOut = async () => { diff --git a/src/app/wardrobe/[id]/page.tsx b/src/app/wardrobe/[id]/page.tsx new file mode 100644 index 0000000..6014104 --- /dev/null +++ b/src/app/wardrobe/[id]/page.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { Button } from "@/components/ui"; +import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; + +interface Garment { + id: string; + name: string | null; + category: string; + sizeLabel: string; + colors: string[]; + seasons: string[]; + occasionTags: string[]; + imageKey: string; + thumbKey: string; + imageUrl: string; + thumbUrl: string; + status: string; + acquiredVia: string | null; + giftFrom: string | null; + visionMetadata: unknown; + createdAt: string; + updatedAt: string; +} + +interface Wear { + id: string; + worn_on: string; + memory_id: string | null; +} + +const STATUS_FLOW = ["active", "stored", "outgrown", "donated"] as const; +const STATUS_COLORS: Record = { + active: "bg-green-100 text-green-700", + stored: "bg-blue-100 text-blue-700", + outgrown: "bg-amber-100 text-amber-700", + donated: "bg-gray-100 text-gray-600", +}; +const CATEGORY_LABELS: Record = { + onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress", + outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory", +}; + +function Chip({ label, color }: { label: string; color?: string }) { + return ( + + {label} + + ); +} + +export default function GarmentDetailPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [garment, setGarment] = useState(null); + const [wears, setWears] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [loggingWear, setLoggingWear] = useState(false); + const [error, setError] = useState(""); + + // Edit state + const [editName, setEditName] = useState(""); + const [editCategory, setEditCategory] = useState(""); + const [editSize, setEditSize] = useState(""); + const [editStatus, setEditStatus] = useState(""); + const [editColors, setEditColors] = useState([]); + const [editSeasons, setEditSeasons] = useState([]); + const [editOccasions, setEditOccasions] = useState([]); + const [editColorInput, setEditColorInput] = useState(""); + const [editAcquiredVia, setEditAcquiredVia] = useState(null); + const [editGiftFrom, setEditGiftFrom] = useState(""); + + const load = async () => { + setLoading(true); + try { + const res = await fetch(`/api/garments/${id}`); + if (!res.ok) { router.replace("/wardrobe"); return; } + const data = await res.json(); + setGarment(data.item); + setWears(data.wears || []); + } catch { + router.replace("/wardrobe"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [id]); + + const startEdit = () => { + if (!garment) return; + setEditName(garment.name || ""); + setEditCategory(garment.category); + setEditSize(garment.sizeLabel); + setEditStatus(garment.status); + setEditColors([...garment.colors]); + setEditSeasons([...garment.seasons]); + setEditOccasions([...garment.occasionTags]); + setEditAcquiredVia(garment.acquiredVia); + setEditGiftFrom(garment.giftFrom || ""); + setEditing(true); + }; + + const saveEdit = async () => { + setSaving(true); + setError(""); + try { + const res = await fetch(`/api/garments/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: editName || null, + category: editCategory, + sizeLabel: editSize, + status: editStatus, + colors: editColors, + seasons: editSeasons, + occasionTags: editOccasions, + acquiredVia: editAcquiredVia, + giftFrom: editGiftFrom || null, + }), + }); + if (!res.ok) throw new Error((await res.json()).error); + const data = await res.json(); + setGarment(data.item); + setEditing(false); + } catch (err) { + setError(`Save failed: ${err}`); + } finally { + setSaving(false); + } + }; + + const logWear = async () => { + setLoggingWear(true); + try { + const res = await fetch(`/api/garments/${id}/wear`, { method: "POST" }); + if (res.ok) { + const data = await res.json(); + setWears(prev => [data.wear, ...prev]); + } + } catch {} + setLoggingWear(false); + }; + + const updateStatus = async (newStatus: string) => { + try { + const res = await fetch(`/api/garments/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + if (res.ok) { + const data = await res.json(); + setGarment(data.item); + } + } catch {} + }; + + const toggleArray = (arr: string[], val: string) => + arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val]; + + if (loading) { + return ( +
+
+ {[0, 150, 300].map(d => ( + + ))} +
+
+ ); + } + + if (!garment) return null; + + return ( +
+ {/* Header */} +
+ +

{garment.name || CATEGORY_LABELS[garment.category] || garment.category}

+ +
+ + {error && ( +
{error}
+ )} + + {!editing ? ( + /* ─── View mode ─── */ +
+ {/* Full-res image */} +
+ {garment.name +
+ + {/* Status + quick actions */} +
+
+ + {garment.status} + + +
+
+ {STATUS_FLOW.filter(s => s !== garment.status).map(s => ( + + ))} +
+
+ + {/* Details */} +
+
+
+

Category

+

{CATEGORY_LABELS[garment.category] || garment.category}

+
+
+

Size

+

{garment.sizeLabel}

+
+ {garment.acquiredVia && ( +
+

Acquired via

+

{garment.acquiredVia}

+
+ )} + {garment.giftFrom && ( +
+

Gift from

+

💝 {garment.giftFrom}

+
+ )} +
+ + {garment.colors.length > 0 && ( +
+

Colors

+
+ {garment.colors.map(c => )} +
+
+ )} + {garment.seasons.length > 0 && ( +
+

Seasons

+
+ {garment.seasons.map(s => )} +
+
+ )} + {garment.occasionTags.length > 0 && ( +
+

Occasions

+
+ {garment.occasionTags.map(t => )} +
+
+ )} +
+ + {/* Wear history */} +
+

+ Wear history + ({wears.length} times) +

+ {wears.length === 0 ? ( +

Not worn yet — tap "Worn today" to start tracking

+ ) : ( +
+ {wears.map(w => ( +
+ 📅 + {new Date(w.worn_on).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })} +
+ ))} +
+ )} +
+
+ ) : ( + /* ─── Edit mode ─── */ +
+
+ + setEditName(e.target.value)} + className="mt-1 w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" + /> +
+ + {/* Category chips */} +
+

Category

+
+ {GARMENT_CATEGORIES.map(c => ( + + ))} +
+
+ + {/* Size chips */} +
+

Size

+
+ {GARMENT_SIZE_ORDER.map(s => ( + + ))} +
+
+ + {/* Status */} +
+

Status

+
+ {STATUS_FLOW.map(s => ( + + ))} +
+
+ + {/* Colors */} +
+

Colors

+
+ {editColors.map(c => ( + + ))} +
+
+ setEditColorInput(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}} + placeholder="Add color…" className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" /> + +
+
+ + {/* Seasons */} +
+

Seasons

+
+ {["summer", "monsoon", "winter"].map(s => ( + + ))} +
+
+ + {/* Occasions */} +
+

Occasions

+
+ {["everyday", "daycare", "festive", "photoshoot"].map(o => ( + + ))} +
+
+ + +
+ )} +
+ ); +} diff --git a/src/app/wardrobe/add/page.tsx b/src/app/wardrobe/add/page.tsx new file mode 100644 index 0000000..2c0787b --- /dev/null +++ b/src/app/wardrobe/add/page.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { useFamily } from "../../FamilyProvider"; +import { Button } from "@/components/ui"; +import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; + +type Step = "capture" | "tagging" | "form"; + +interface VisionResult { + name: string; + category: string; + colors: string[]; + seasons: string[]; + occasion_tags: string[]; +} + +const SEASON_OPTIONS = ["summer", "monsoon", "winter"]; +const OCCASION_OPTIONS = ["everyday", "daycare", "festive", "photoshoot"]; +const ACQUIRED_OPTIONS = ["bought", "gift", "handmedown"]; + +const CATEGORY_LABELS: Record = { + onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress", + outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory", +}; + +const CATEGORY_COLORS: Record = { + onesie: "bg-pink-100 text-pink-700", top: "bg-blue-100 text-blue-700", + bottom: "bg-indigo-100 text-indigo-700", dress: "bg-purple-100 text-purple-700", + outerwear: "bg-teal-100 text-teal-700", sleepwear: "bg-amber-100 text-amber-700", + accessory: "bg-rose-100 text-rose-700", +}; + +function ChipRow({ + label, options, selected, onChange, required, optionLabel, +}: { + label: string; + options: readonly T[]; + selected: T[]; + onChange: (v: T[]) => void; + required?: boolean; + optionLabel?: (v: T) => string; +}) { + const toggle = (v: T) => { + if (selected.includes(v)) onChange(selected.filter(x => x !== v)); + else onChange([...selected, v]); + }; + return ( +
+

+ {label}{required && *} +

+
+ {options.map(o => ( + + ))} +
+
+ ); +} + +function SingleChipRow({ + label, options, selected, onChange, required, optionLabel, +}: { + label: string; + options: readonly T[]; + selected: T | null; + onChange: (v: T) => void; + required?: boolean; + optionLabel?: (v: T) => string; +}) { + return ( +
+

+ {label}{required && *} +

+
+ {options.map(o => ( + + ))} +
+
+ ); +} + +export default function AddGarmentPage() { + const { childId } = useFamily(); + const router = useRouter(); + const fileRef = useRef(null); + + const [step, setStep] = useState("capture"); + const [preview, setPreview] = useState(null); + const [thumbUrl, setThumbUrl] = useState(null); + const [imageKey, setImageKey] = useState(""); + const [thumbKey, setThumbKey] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + // Form fields + const [name, setName] = useState(""); + const [category, setCategory] = useState(null); + const [sizeLabel, setSizeLabel] = useState(null); + const [colors, setColors] = useState([]); + const [colorInput, setColorInput] = useState(""); + const [seasons, setSeasons] = useState([]); + const [occasionTags, setOccasionTags] = useState([]); + const [acquiredVia, setAcquiredVia] = useState(null); + const [giftFrom, setGiftFrom] = useState(""); + const [visionMetadata, setVisionMetadata] = useState(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setError(""); + setStep("tagging"); + + // Show local preview immediately + const reader = new FileReader(); + reader.onload = ev => setPreview(ev.target?.result as string); + reader.readAsDataURL(file); + + // Upload to R2 + const form = new FormData(); + form.append("file", file); + let uploadRes; + try { + uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form }); + if (!uploadRes.ok) throw new Error((await uploadRes.json()).error); + } catch (err) { + setError(`Upload failed: ${err}`); + setStep("capture"); + return; + } + const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json(); + setImageKey(ik); + setThumbKey(tk); + setThumbUrl(tu); + + // Vision tagging + let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] }; + try { + const tagRes = await fetch("/api/garments/tag", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageKey: ik }), + }); + if (tagRes.ok) { + const tagData = await tagRes.json(); + vision = tagData; + } + } catch { + // Vision failure is non-fatal — proceed with empty defaults + } + + setVisionMetadata(vision); + setName(vision.name || ""); + setCategory(vision.category || null); + setColors(vision.colors || []); + setSeasons(vision.seasons || []); + setOccasionTags(vision.occasion_tags || []); + setStep("form"); + }; + + const addColor = () => { + const c = colorInput.trim().toLowerCase(); + if (c && !colors.includes(c)) setColors([...colors, c]); + setColorInput(""); + }; + + const handleSave = async () => { + if (!childId || !category || !sizeLabel) return; + setSaving(true); + setError(""); + try { + const res = await fetch("/api/garments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + childId, + name: name || null, + category, + sizeLabel, + colors, + seasons, + occasionTags, + imageKey, + thumbKey, + acquiredVia: acquiredVia || null, + giftFrom: giftFrom || null, + visionMetadata, + }), + }); + if (!res.ok) throw new Error((await res.json()).error); + handleAddAnother(); + } catch (err) { + setError(`Save failed: ${err}`); + } finally { + setSaving(false); + } + }; + + const handleAddAnother = () => { + setStep("capture"); + setPreview(null); + setThumbUrl(null); + setImageKey(""); + setThumbKey(""); + setName(""); + setCategory(null); + setSizeLabel(null); + setColors([]); + setColorInput(""); + setSeasons([]); + setOccasionTags([]); + setAcquiredVia(null); + setGiftFrom(""); + setVisionMetadata(null); + setError(""); + if (fileRef.current) fileRef.current.value = ""; + }; + + return ( +
+ {/* Header */} +
+ +

Add Garment

+ {step === "form" && ( + + ✨ Vision pre-filled + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Step 1: Capture */} + {step === "capture" && ( +
+
fileRef.current?.click()} + className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800" + > + 👚 +
+

Take or upload a photo

+

Tip: flat-lay on a light surface for best tagging

+
+ + 📷 Add Photo + +
+ +
+ )} + + {/* Step 2: Tagging spinner */} + {step === "tagging" && ( +
+ {preview && ( +
+ preview +
+ )} +
+
+ {[0, 150, 300].map(d => ( + + ))} +
+

Analysing garment…

+

Vision is detecting category & colors

+
+
+ )} + + {/* Step 3: Form */} + {step === "form" && ( +
+ {/* Thumbnail */} +
+ {(thumbUrl || preview) && ( +
+ garment +
+ )} +
+ + setName(e.target.value)} + placeholder="e.g. Striped blue onesie" + className="mt-1 w-full px-3 py-2 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300" + /> +
+
+ + {/* Category */} +
+ setCategory(v)} + required + optionLabel={v => CATEGORY_LABELS[v] || v} + /> +
+ + {/* Size — required, not pre-filled */} +
+ setSizeLabel(v)} + required + /> + {!sizeLabel && ( +

+ ⚠ Size must be set manually — vision cannot guess it +

+ )} +
+ + {/* Colors */} +
+

Colors

+
+ {colors.map(c => ( + + ))} +
+
+ setColorInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && addColor()} + placeholder="Add color…" + className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" + /> + +
+
+ + {/* Seasons */} +
+ +
+ + {/* Occasions */} +
+ +
+ + {/* Optional fields */} +
+

Optional

+
+

Acquired via

+
+ {ACQUIRED_OPTIONS.map(o => ( + + ))} +
+
+ {acquiredVia === "gift" && ( +
+

Gift from

+ setGiftFrom(e.target.value)} + placeholder="e.g. Nani" + className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" + /> +
+ )} +
+ + {/* Action buttons */} +
+ + {!sizeLabel && ( +

+ Please select a size to save +

+ )} + +
+
+ )} +
+ ); +} diff --git a/src/app/wardrobe/outfit/page.tsx b/src/app/wardrobe/outfit/page.tsx new file mode 100644 index 0000000..26b37b4 --- /dev/null +++ b/src/app/wardrobe/outfit/page.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useFamily } from "../../FamilyProvider"; +import { Button } from "@/components/ui"; + +interface OutfitItem { + id: string; + name: string | null; + category: string; + sizeLabel: string; + thumbUrl: string; + imageUrl: string; + colors: string[]; +} + +interface OutfitSuggestion { + label: string; + items: OutfitItem[]; +} + +interface OutfitResponse { + weatherBasis: string; + weather: { tempC: number; season: string }; + outfits: OutfitSuggestion[]; +} + +const OCCASION_OPTS = ["", "everyday", "daycare", "festive", "photoshoot"] as const; +const SEASON_ICON: Record = { + summer: "☀️", monsoon: "🌧️", winter: "❄️", +}; + +export default function OutfitSuggestionPage() { + const { childId } = useFamily(); + const router = useRouter(); + + const [occasion, setOccasion] = useState(""); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [savedOutfitName, setSavedOutfitName] = useState>({}); + const [savedIdx, setSavedIdx] = useState>(new Set()); + + const fetchSuggestions = async () => { + if (!childId) return; + setLoading(true); + try { + const params = new URLSearchParams({ childId }); + if (occasion) params.set("occasion", occasion); + const res = await fetch(`/api/garments/outfit?${params}`); + const data = await res.json(); + setResult(data); + } catch {} + setLoading(false); + }; + + useEffect(() => { fetchSuggestions(); }, [childId]); + + const saveOutfit = async (idx: number, outfit: OutfitSuggestion) => { + const name = savedOutfitName[idx]?.trim() || outfit.label; + if (!childId) return; + try { + const res = await fetch("/api/garments/outfits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + childId, + name, + garmentIds: outfit.items.map(i => i.id), + occasionTags: occasion ? [occasion] : [], + }), + }); + if (res.ok) setSavedIdx(prev => new Set([...prev, idx])); + } catch {} + }; + + return ( +
+
+ +

✨ Today's Outfit

+ +
+ + {/* Weather basis */} + {result && ( +
+ {SEASON_ICON[result.weather.season] || "🌤️"} +
+

{result.weatherBasis}

+

Suggestions filtered for {result.weather.season} garments

+
+
+ )} + + {/* Occasion filter */} +
+ {OCCASION_OPTS.map(o => ( + + ))} +
+ + {loading ? ( +
+
+ {[0, 150, 300].map(d => ( + + ))} +
+

Fetching weather + building outfits…

+
+ ) : result && result.outfits.length === 0 ? ( +
+ 🌂 +

No outfits for today's weather

+

+ Add more {result.weather.season} garments to your wardrobe +

+ +
+ ) : result ? ( +
+ {result.outfits.map((outfit, idx) => ( +
+
+ + Outfit {idx + 1} — {outfit.label} + + {outfit.items[0]?.colors?.length > 0 && ( + {outfit.items.flatMap(i => i.colors).slice(0, 3).join(", ")} + )} +
+ +
+ {outfit.items.map(item => ( +
+
+ {item.name +
+

+ {item.name || item.category} +

+ + {item.sizeLabel} + +
+ ))} +
+ + {/* Save outfit */} + {savedIdx.has(idx) ? ( +
✓ Saved to wardrobe
+ ) : ( +
+ setSavedOutfitName(prev => ({ ...prev, [idx]: e.target.value }))} + placeholder={outfit.label} + className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" + /> + +
+ )} +
+ ))} + + +
+ ) : null} +
+ ); +} diff --git a/src/app/wardrobe/packing/page.tsx b/src/app/wardrobe/packing/page.tsx new file mode 100644 index 0000000..16a3d78 --- /dev/null +++ b/src/app/wardrobe/packing/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useFamily } from "../../FamilyProvider"; +import { Button } from "@/components/ui"; + +interface PackingItem { + id: string; + name: string; + sizeLabel: string; + thumbUrl: string; + checked: boolean; +} + +type PackingGroups = Record; + +const SEASON_OPTS = ["summer", "monsoon", "winter"] as const; +const OCCASION_OPTS = ["everyday", "daycare", "festive", "photoshoot"] as const; +const CATEGORY_EMOJI: Record = { + onesie: "👶", top: "👕", bottom: "👖", dress: "👗", + outerwear: "🧥", sleepwear: "😴", accessory: "🎀", +}; + +export default function PackingListPage() { + const { childId } = useFamily(); + const router = useRouter(); + + const [season, setSeason] = useState(""); + const [occasion, setOccasion] = useState(""); + const [groups, setGroups] = useState(null); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + const generate = async () => { + if (!childId) return; + setLoading(true); + try { + const params = new URLSearchParams({ childId }); + if (season) params.set("season", season); + if (occasion) params.set("occasion", occasion); + const res = await fetch(`/api/garments/packing?${params}`); + const data = await res.json(); + setGroups(data.groups || {}); + setTotal(data.total || 0); + } catch {} + setLoading(false); + }; + + const toggleItem = (cat: string, itemId: string) => { + if (!groups) return; + setGroups(prev => ({ + ...prev!, + [cat]: prev![cat].map(i => i.id === itemId ? { ...i, checked: !i.checked } : i), + })); + }; + + const checkedCount = groups + ? Object.values(groups).flat().filter(i => i.checked).length + : 0; + + return ( +
+
+ +

🧳 Packing List

+
+ + {/* Filters */} +
+
+

Season

+
+ + {SEASON_OPTS.map(s => ( + + ))} +
+
+ +
+

Occasion

+
+ + {OCCASION_OPTS.map(o => ( + + ))} +
+
+ + +
+ + {groups && ( + <> +
+

{total} garments found · {checkedCount} packed

+ {total === 0 && ( +

No matching garments — try broader filters

+ )} +
+ +
+ {Object.entries(groups).map(([cat, items]) => ( +
+
+ {CATEGORY_EMOJI[cat] || "👗"} + {cat} + {items.length} +
+ {items.map(item => ( + + ))} +
+ ))} +
+ + )} +
+ ); +} diff --git a/src/app/wardrobe/page.tsx b/src/app/wardrobe/page.tsx new file mode 100644 index 0000000..a2be4cf --- /dev/null +++ b/src/app/wardrobe/page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useFamily } from "../FamilyProvider"; +import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; + +interface Garment { + id: string; + name: string | null; + category: string; + sizeLabel: string; + thumbUrl: string; + status: string; + seasons: string[]; + occasionTags: string[]; + colors: string[]; +} + +interface OutgrowthNudge { + candidates: { id: string; sizeLabel: string }[]; + currentSizeLabel: string | null; +} + +const STATUS_OPTS = [ + { value: "active", label: "Active" }, + { value: "stored", label: "Stored" }, + { value: "outgrown", label: "Outgrown" }, + { value: "donated", label: "Donated" }, +]; + +const CATEGORY_EMOJI: Record = { + onesie: "👶", top: "👕", bottom: "👖", dress: "👗", + outerwear: "🧥", sleepwear: "😴", accessory: "🎀", +}; + +export default function WardrobePage() { + const { childId } = useFamily(); + const router = useRouter(); + + const [garments, setGarments] = useState([]); + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState("active"); + const [filterCategory, setFilterCategory] = useState(""); + const [filterSize, setFilterSize] = useState(""); + const [filterSeason, setFilterSeason] = useState(""); + const [nudge, setNudge] = useState(null); + const [nudgeDismissed, setNudgeDismissed] = useState(false); + + const fetchGarments = useCallback(async () => { + if (!childId) return; + setLoading(true); + try { + const params = new URLSearchParams({ childId, status }); + if (filterCategory) params.set("category", filterCategory); + if (filterSize) params.set("sizeLabel", filterSize); + if (filterSeason) params.set("season", filterSeason); + const res = await fetch(`/api/garments?${params}`); + const data = await res.json(); + setGarments(data.items || []); + } catch { + setGarments([]); + } finally { + setLoading(false); + } + }, [childId, status, filterCategory, filterSize, filterSeason]); + + const fetchNudge = useCallback(async () => { + if (!childId || nudgeDismissed) return; + try { + const res = await fetch(`/api/garments/outgrowth?childId=${childId}`); + const data = await res.json(); + if (data.candidates?.length > 0) setNudge(data); + } catch {} + }, [childId, nudgeDismissed]); + + useEffect(() => { fetchGarments(); }, [fetchGarments]); + useEffect(() => { fetchNudge(); }, [fetchNudge]); + + return ( +
+ {/* Header */} +
+ +

Wardrobe 👗

+
+ ✨ + 🧳 + 💾 + + Add +
+
+ + {/* Outgrowth nudge */} + {nudge && !nudgeDismissed && ( +
+ 📦 +
+

+ {nudge.candidates.length} item{nudge.candidates.length !== 1 ? "s" : ""} not worn in 45+ days and may be outgrown +

+

+ Current size: {nudge.currentSizeLabel} +

+
+ + +
+
+
+ )} + + {/* Filters */} +
+ {/* Status row */} +
+ {STATUS_OPTS.map(s => ( + + ))} +
+ + {/* Category row */} +
+ + {GARMENT_CATEGORIES.map(c => ( + + ))} +
+ + {/* Size row */} +
+ + {GARMENT_SIZE_ORDER.map(s => ( + + ))} +
+ + {/* Season row */} +
+ {["", "summer", "monsoon", "winter"].map(s => ( + + ))} +
+
+ + {/* Grid */} + {loading ? ( +
+ {[...Array(9)].map((_, i) => ( +
+ ))} +
+ ) : garments.length === 0 ? ( +
+ 👚 +

No garments yet

+

Add your first garment to build the wardrobe catalogue

+ + + Add Garment + +
+ ) : ( +
+ {garments.map(g => ( + +
+ {g.name + + {g.sizeLabel} + +
+
+

+ {g.name || `${CATEGORY_EMOJI[g.category] || ""} ${g.category}`} +

+
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/wardrobe/saved-outfits/page.tsx b/src/app/wardrobe/saved-outfits/page.tsx new file mode 100644 index 0000000..f71db17 --- /dev/null +++ b/src/app/wardrobe/saved-outfits/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useFamily } from "../../FamilyProvider"; + +interface SavedOutfit { + id: string; + name: string; + garment_ids: string[]; + occasion_tags: string[]; + created_at: string; +} + +interface GarmentThumb { + id: string; + thumbUrl: string; + name: string | null; + category: string; +} + +export default function SavedOutfitsPage() { + const { childId } = useFamily(); + const router = useRouter(); + + const [outfits, setOutfits] = useState([]); + const [thumbs, setThumbs] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!childId) return; + (async () => { + try { + const res = await fetch(`/api/garments/outfits?childId=${childId}`); + const data = await res.json(); + setOutfits(data.items || []); + + // Fetch thumbnails for garment IDs referenced by outfits + const allIds = [...new Set((data.items || []).flatMap((o: SavedOutfit) => o.garment_ids))] as string[]; + if (allIds.length > 0) { + const garmentRes = await fetch(`/api/garments?childId=${childId}&status=active`); + const garmentData = await garmentRes.json(); + const map: Record = {}; + for (const g of garmentData.items || []) { + map[g.id] = { id: g.id, thumbUrl: g.thumbUrl, name: g.name, category: g.category }; + } + setThumbs(map); + } + } catch {} + setLoading(false); + })(); + }, [childId]); + + const deleteOutfit = async (id: string) => { + try { + const res = await fetch(`/api/garments/outfits/${id}`, { method: "DELETE" }); + if (res.ok) setOutfits(prev => prev.filter(o => o.id !== id)); + } catch {} + }; + + return ( +
+
+ +

💾 Saved Outfits

+ + + New + +
+ + {loading ? ( +
+
+ {[0, 150, 300].map(d => ( + + ))} +
+
+ ) : outfits.length === 0 ? ( +
+ 👗 +

No saved outfits yet

+

Save combinations from the outfit suggestion screen

+ + Get suggestions + +
+ ) : ( +
+ {outfits.map(outfit => ( +
+
+
+

{outfit.name}

+ {outfit.occasion_tags?.length > 0 && ( +
+ {outfit.occasion_tags.map(t => ( + + {t} + + ))} +
+ )} +
+ +
+ +
+ {outfit.garment_ids.slice(0, 4).map(gid => { + const g = thumbs[gid]; + if (!g) return ( +
+ 👗 +
+ ); + return ( +
+
+ {g.name +
+
+ ); + })} + {outfit.garment_ids.length > 4 && ( +
+ +{outfit.garment_ids.length - 4} +
+ )} +
+ +

+ {new Date(outfit.created_at).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })} +

+
+ ))} +
+ )} +
+ ); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ddb7738..0c746e5 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -15,3 +15,4 @@ export * from "./admin"; export * from "./support"; export * from "./ai"; export * from "./affiliate"; +export * from "./wardrobe"; diff --git a/src/db/schema/wardrobe.ts b/src/db/schema/wardrobe.ts new file mode 100644 index 0000000..93762f9 --- /dev/null +++ b/src/db/schema/wardrobe.ts @@ -0,0 +1,131 @@ +import { + pgTable, + text, + timestamp, + uuid, + date, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { families } from "./family"; +import { children } from "./family"; +import { memories } from "./media"; + +// --------------------------------------------------------------------------- +// Wardrobe schema +// +// RLS NOTE: garments and garment_wears carry a `family_isolation` policy in +// prod (see drizzle/manual/06-wardrobe-rls.sql). Policies are not modelled +// in these pgTable definitions — they live in the DB and are managed +// separately. Do not assume Drizzle will recreate them. +// --------------------------------------------------------------------------- + +export const garments = pgTable( + "garments", + { + id: uuid("id").primaryKey().defaultRandom(), + familyId: uuid("family_id") + .notNull() + .references(() => families.id), + childId: uuid("child_id") + .notNull() + .references(() => children.id), + name: text("name"), + category: text("category").notNull(), + sizeLabel: text("size_label").notNull(), + colors: text("colors").array().default([]), + seasons: text("seasons").array().default([]), + occasionTags: text("occasion_tags").array().default([]), + imageKey: text("image_key").notNull(), + thumbKey: text("thumb_key").notNull(), + status: text("status").notNull().default("active"), + acquiredVia: text("acquired_via"), + giftFrom: text("gift_from"), + visionMetadata: jsonb("vision_metadata"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("garments_family_idx").on(table.familyId), + index("garments_child_idx").on(table.childId), + index("garments_status_idx").on(table.status), + ] +); + +export const garmentWears = pgTable( + "garment_wears", + { + id: uuid("id").primaryKey().defaultRandom(), + familyId: uuid("family_id") + .notNull() + .references(() => families.id), + garmentId: uuid("garment_id") + .notNull() + .references(() => garments.id, { onDelete: "cascade" }), + wornOn: date("worn_on").notNull(), + memoryId: uuid("memory_id").references(() => memories.id), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("garment_wears_garment_idx").on(table.garmentId), + index("garment_wears_family_idx").on(table.familyId), + ] +); + +export type Garment = typeof garments.$inferSelect; +export type NewGarment = typeof garments.$inferInsert; +export type GarmentWear = typeof garmentWears.$inferSelect; +export type NewGarmentWear = typeof garmentWears.$inferInsert; + +export const GARMENT_CATEGORIES = [ + "onesie", + "top", + "bottom", + "dress", + "outerwear", + "sleepwear", + "accessory", +] as const; + +export type GarmentCategory = (typeof GARMENT_CATEGORIES)[number]; + +export const GARMENT_SIZE_ORDER = [ + "preemie", + "newborn", + "0-3m", + "3-6m", + "6-9m", + "9-12m", + "12-18m", + "18-24m", + "2-3y", + "3-4y", + "4-5y", +] as const; + +export type GarmentSizeLabel = (typeof GARMENT_SIZE_ORDER)[number]; + +// W9: Saved outfits +export const outfits = pgTable( + "outfits", + { + id: uuid("id").primaryKey().defaultRandom(), + familyId: uuid("family_id") + .notNull() + .references(() => families.id), + childId: uuid("child_id") + .notNull() + .references(() => children.id), + name: text("name").notNull(), + garmentIds: uuid("garment_ids").array().notNull().default([]), + occasionTags: text("occasion_tags").array().default([]), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("outfits_family_idx").on(table.familyId), + index("outfits_child_idx").on(table.childId), + ] +); + +export type Outfit = typeof outfits.$inferSelect; +export type NewOutfit = typeof outfits.$inferInsert; diff --git a/src/lib/wardrobe/outfit.ts b/src/lib/wardrobe/outfit.ts new file mode 100644 index 0000000..307d420 --- /dev/null +++ b/src/lib/wardrobe/outfit.ts @@ -0,0 +1,74 @@ +// Neutral colors that pair well with anything +const NEUTRALS = new Set(["white", "cream", "beige", "grey", "gray", "black", "off-white", "ivory", "nude"]); + +export function colorsCompatible(a: string[], b: string[]): boolean { + if (!a.length || !b.length) return true; // no color info → assume compatible + const hasNeutral = (arr: string[]) => arr.some(c => NEUTRALS.has(c.toLowerCase())); + if (hasNeutral(a) || hasNeutral(b)) return true; + // Same hue family → monochromatic, fine + const normalize = (c: string) => c.toLowerCase().replace(/light |dark |pale |deep /, ""); + const aHues = a.map(normalize); + const bHues = b.map(normalize); + return aHues.some(h => bHues.includes(h)); +} + +export interface GarmentRow { + id: string; + name: string | null; + category: string; + sizeLabel: string; + colors: string[]; + seasons: string[]; + occasionTags: string[]; + thumbKey: string; + imageKey: string; +} + +export interface OutfitSuggestion { + items: GarmentRow[]; + label: string; +} + +export function buildOutfits(garments: GarmentRow[], max = 3): OutfitSuggestion[] { + const tops = garments.filter(g => g.category === "top"); + const bottoms = garments.filter(g => g.category === "bottom"); + const dresses = garments.filter(g => g.category === "dress"); + const onesies = garments.filter(g => g.category === "onesie"); + + const results: OutfitSuggestion[] = []; + + // Dresses and onesies are standalone complete outfits + for (const item of [...dresses, ...onesies]) { + if (results.length >= max) break; + results.push({ items: [item], label: item.category === "dress" ? "Dress" : "Onesie" }); + } + + // Pair tops + bottoms + for (const top of tops) { + if (results.length >= max) break; + for (const bottom of bottoms) { + if (results.length >= max) break; + if (colorsCompatible(top.colors, bottom.colors)) { + results.push({ items: [top, bottom], label: "Top + Bottom" }); + break; // one bottom per top + } + } + } + + // If not enough pairs, add mismatched ones anyway + if (results.length < max) { + for (const top of tops) { + if (results.length >= max) break; + for (const bottom of bottoms) { + if (results.length >= max) break; + const alreadyAdded = results.some(r => r.items.some(i => i.id === top.id || i.id === bottom.id)); + if (!alreadyAdded) { + results.push({ items: [top, bottom], label: "Top + Bottom" }); + break; + } + } + } + } + + return results; +} diff --git a/src/lib/wardrobe/vision.ts b/src/lib/wardrobe/vision.ts new file mode 100644 index 0000000..b23b898 --- /dev/null +++ b/src/lib/wardrobe/vision.ts @@ -0,0 +1,152 @@ +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { GARMENT_CATEGORIES, type GarmentCategory } from "@/db/schema/wardrobe"; + +const VISION_MODEL = process.env.VISION_MODEL || "gemini-flash"; +const LITELLM_URL = process.env.LITELLM_BASE_URL; +const LITELLM_KEY = process.env.LITELLM_API_KEY; + +interface GarmentVisionResult { + name: string; + category: GarmentCategory; + colors: string[]; + seasons: string[]; + occasion_tags: string[]; +} + +const SAFE_DEFAULT: GarmentVisionResult = { + name: "", + category: "top", + colors: [], + seasons: [], + occasion_tags: [], +}; + +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!, + }; +} + +async function fetchFromR2(key: string): Promise { + const R2 = getR2Config(); + const client = new S3Client({ + region: "auto", + endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey }, + }); + const res = await client.send(new GetObjectCommand({ Bucket: R2.bucket, Key: key })); + const chunks: Buffer[] = []; + for await (const chunk of res.Body as AsyncIterable) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +export async function tagGarment(imageKey: string): Promise { + if (!LITELLM_URL || !LITELLM_KEY) { + console.warn("[wardrobe/vision] LiteLLM not configured — returning defaults"); + return { ...SAFE_DEFAULT }; + } + + let imageBuffer: Buffer; + try { + imageBuffer = await fetchFromR2(imageKey); + } catch (err) { + console.error("[wardrobe/vision] R2 fetch failed:", err); + return { ...SAFE_DEFAULT }; + } + + const base64 = imageBuffer.toString("base64"); + + const systemPrompt = `You are a baby clothing tagger. Respond ONLY with a JSON object — no prose, no markdown fences. +The JSON must have exactly these keys: +- name: a short descriptive name for the garment (string, e.g. "Striped blue onesie") +- category: exactly one of: onesie|top|bottom|dress|outerwear|sleepwear|accessory +- colors: array of color names visible in the garment (e.g. ["blue","white"]) +- seasons: subset of: summer|monsoon|winter (based on fabric weight and style) +- occasion_tags: subset of: everyday|daycare|festive|photoshoot +Do NOT attempt to guess the garment size. Do NOT include any other keys.`; + + let raw = ""; + try { + const response = await fetch(`${LITELLM_URL}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${LITELLM_KEY}`, + }, + body: JSON.stringify({ + model: VISION_MODEL, + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: `data:image/jpeg;base64,${base64}` }, + }, + { + type: "text", + text: "Tag this baby garment.", + }, + ], + }, + ], + max_tokens: 200, + temperature: 0.1, + }), + }); + + if (!response.ok) { + console.error("[wardrobe/vision] API error:", response.status); + return { ...SAFE_DEFAULT }; + } + + const data = await response.json(); + raw = data.choices?.[0]?.message?.content ?? ""; + } catch (err) { + console.error("[wardrobe/vision] fetch failed:", err); + return { ...SAFE_DEFAULT }; + } + + // Strip accidental markdown fences + const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim(); + + let parsed: Record; + try { + parsed = JSON.parse(cleaned); + } catch { + console.warn("[wardrobe/vision] parse failed, raw:", raw); + return { ...SAFE_DEFAULT }; + } + + // Validate category against allowed enum — treat hallucinated values as default + const rawCategory = String(parsed.category ?? "").toLowerCase() as GarmentCategory; + const safeCategory: GarmentCategory = GARMENT_CATEGORIES.includes(rawCategory) + ? rawCategory + : "top"; + + const toStringArray = (v: unknown): string[] => { + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === "string"); + }; + + return { + name: typeof parsed.name === "string" ? parsed.name : "", + category: safeCategory, + colors: toStringArray(parsed.colors), + seasons: toStringArray(parsed.seasons).filter(s => + ["summer", "monsoon", "winter"].includes(s) + ), + occasion_tags: toStringArray(parsed.occasion_tags).filter(t => + ["everyday", "daycare", "festive", "photoshoot"].includes(t) + ), + }; +} diff --git a/src/lib/wardrobe/weather.ts b/src/lib/wardrobe/weather.ts new file mode 100644 index 0000000..c467668 --- /dev/null +++ b/src/lib/wardrobe/weather.ts @@ -0,0 +1,43 @@ +// Gurugram coordinates (product is explicitly Gurugram-targeted) +const LAT = 28.4595; +const LON = 77.0266; + +export interface WeatherSnapshot { + tempC: number; + description: string; + season: "summer" | "monsoon" | "winter"; +} + +// Maps temperature band to Gurugram season label. +// < 15°C → winter +// 15–28°C → monsoon (shoulder / rain season) +// > 28°C → summer +function tempToSeason(tempC: number): "summer" | "monsoon" | "winter" { + if (tempC < 15) return "winter"; + if (tempC <= 28) return "monsoon"; + return "summer"; +} + +export async function getGurgaonWeather(): Promise { + try { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}¤t_weather=true`; + const res = await fetch(url, { next: { revalidate: 1800 } }); // cache 30 min + if (!res.ok) throw new Error(`weather API ${res.status}`); + const data = await res.json(); + const tempC: number = data.current_weather?.temperature ?? 30; + const season = tempToSeason(tempC); + const descriptions: Record = { + summer: "hot", + monsoon: "warm", + winter: "cool", + }; + return { + tempC, + description: descriptions[season], + season, + }; + } catch { + // Fallback to summer (Gurugram default) + return { tempC: 35, description: "hot", season: "summer" }; + } +}