# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Development
pnpm dev          # Start dev server at http://localhost:3000
pnpm build        # Production build (uses Turbopack by default)
pnpm start        # Start production server
# Note: If Turbopack fails, use: pnpm build -- --webpack

# Database (direct SQL or docker)
docker-compose -f docker-compose.dev.yml exec db psql -U postgres -d tia_dev
```

**Note:** Running from `/Users/manohar_air/MyProjects/Tia/tia` directory.
**Rule:** ALWAYS run `pnpm build` locally BEFORE git commit/push.

---

## Architecture

### Tech Stack

- **Framework:** Next.js 16 with App Router (src/app/)
- **Database:** PostgreSQL 16 with pgvector + Drizzle ORM
- **Auth:** Database sessions with httpOnly cookies (`tia_session`)
- **AI:** LiteLLM gateway → MiniMax model (minimax-2.7)
- **Storage:** Cloudflare R2 for media uploads
- **Email:** Resend (transactional emails)
- **Styling:** Tailwind CSS v4
- **Deploy:** Dokploy (Docker-based, auto-runs migrations on deploy)

### Project Structure

```
src/
├── app/                    # Next.js App Router pages
│   ├── api/               # API routes (auth, logs, ai, growth, etc.)
│   ├── page.tsx            # Home (Quick Log + AI card + baby card)
│   ├── ai/               # AI chat page with sidebar
│   ├── medical/           # Vaccination tracking
│   ├── growth/           # Growth charts
│   ├── memories/         # Photo gallery
│   ├── menu/             # Navigation menu
│   ├── onboarding/      # First-time setup
│   ├── settings/          # Settings with theme picker, invite members
│   ├── profile/          # Parent user profile (avatar, name, email)
│   ├── login/            # User login (email + password)
│   ├── admin/            # Admin panel (server component layout)
│   └── admin-login/     # Admin login (separate, NOT under admin layout)
├── ThemeProvider.tsx       # Theme context (light/dark/system/time)
├── FamilyProvider.tsx      # Family/child context (resolves from session)
├── lib/
│   ├── auth.ts            # requireFamily(), requireOwnership()
│   ├── email.ts           # sendVerificationEmail(), sendFamilyInviteEmail()
│   └── admin-auth.ts      # requireAdmin() for API, verifyAdminSession() for server components
drizzle/                  # Database migrations (SQL files)
drizzle/meta/_journal.json # Migration order — MUST update when adding new SQL files
```

---

## Database Migrations

### How it works (Dokploy auto-deploy)
- `Dockerfile CMD`: `node migrate.mjs && node server.js` — migrations run first, app boots after
- `src/db/migrate.ts`: reads every SQL file in `drizzle/` and applies any not yet in `__drizzle_migrations`
- `drizzle/meta/_journal.json`: ordered list of migration files — **must be updated** when adding a new SQL file

### Adding a new migration
1. Create `drizzle/NNNN_description.sql` with your SQL (use `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`)
2. Add an entry to `drizzle/meta/_journal.json` with the next `idx` and matching `tag`
3. Push to git → Dokploy auto-applies on next deploy

> ⚠️ **CRITICAL — the `when` timestamp must be greater than every existing entry.**
> Drizzle's migrator only applies a migration when its `when` (folderMillis) is **greater than the
> max `created_at` already in `drizzle.__drizzle_migrations`**. It does NOT diff by hash/idx.
> Migrations `0003`–`0010` were hand-added with 2025-era `when` values (1748…/1749…) that are
> SMALLER than the 2026 baseline (0000–0002 ≈ 1779539431897), so drizzle **silently skipped all of
> them** — they only ever got applied via the debug-migration hot-apply endpoint.
> For any new migration: set `when` to a **current `Date.now()`** (must be > 1779539431897). If you
> ever need drizzle to re-apply a skipped one, its SQL must be idempotent (`IF NOT EXISTS`); note
> `0003_circles.sql` is NOT idempotent, so do not let drizzle re-run it.

### Hot-fix: apply migration without waiting for redeploy
Use the **debug-migration endpoint** (same pattern as the Circles implementation):

```
POST /api/debug-migration
Header: x-run-migration: yes
```

This runs SQL directly through the app's live DB connection. Steps use `IF NOT EXISTS` so safe to re-run.

To add a new hot-fix: add `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` to the `steps` array in
`src/app/api/debug-migration/route.ts`, push, and immediately call the endpoint from Chrome:

```javascript
// In browser console on tia.manohargupta.com
const res = await fetch('/api/debug-migration', {
  method: 'POST',
  headers: { 'x-run-migration': 'yes' }
});
console.log(await res.json());
```

The GET endpoint also shows which migrations have been applied and email config status.

---

## Authentication

### User auth (email + password)
1. User visits `/login` with email + password
2. `POST /api/auth/signin` verifies bcrypt hash, creates session in `sessions` table
3. Session token stored in **httpOnly cookie** `tia_session` (NOT localStorage!)
4. All data routes use `requireFamily()` from `@/lib/auth`

### Admin auth
- Login at `/admin-login` (username: `admin`, password: from env)
- Sets `tia_admin_session` httpOnly cookie
- **Admin layout** (`src/app/admin/layout.tsx`) is a **server component** — calls `verifyAdminSession()` from `@/lib/admin-auth` and `redirect('/admin-login')` if not authed
- **Admin sub-pages** (`families`, `users`, `analytics`, etc.) use `credentials: 'include'` on all fetches — NO localStorage tokens, NO Bearer headers
- `AdminSidebar.tsx` is a separate `"use client"` component for the interactive sidebar

### FamilyProvider
Resolves family from database session on login. Skip for admin routes.

```typescript
import { useFamily } from "./FamilyProvider";
const { familyId, familyName, child, children, tier, memberCount, updateChildImage } = useFamily();
// updateChildImage(childId, imageUrl | null) — updates in-memory state after photo change
```

---

## Photo Uploads (R2)

### Critical: CORS — never do a direct cross-origin PUT from the browser
Direct `PUT` to R2 presigned URLs is cross-origin and **blocked by the browser**. Always proxy through the server.

### 3-step upload pattern (used everywhere)
1. `POST /api/[init-endpoint]` with `{ contentType, filename }` → get `{ key, publicUrl }`
2. `PUT /api/upload?key=...&contentType=...` — server proxies the file to R2
3. `PATCH /api/[save-endpoint]` with `{ [imageField]: publicUrl }` → saves URL to DB + deletes old R2 object

### Photo storage — two completely separate features

| | **Parent profile photo** | **Baby card photo** |
|---|---|---|
| Page | `/profile` | Homepage (`/`) |
| DB column | `users.image` | `children.image_url` |
| API | `POST/PATCH/DELETE /api/auth/avatar` | `POST/PATCH /api/children/[id]` |
| R2 prefix | `avatars/{userId}/...` | `profiles/{childId}/...` |

### Orphan cleanup pattern
Before overwriting an image URL in the DB, fetch the old URL, extract the R2 key, verify it's under a controlled prefix (`avatars/` or `profiles/`), then `DeleteObjectCommand`. Applied in both avatar and children routes.

### users.image — NOT users.avatar_url
The `users` table column is called **`image`** (not `avatar_url`). `avatar_url` belongs to `member_profiles`.
Always use `u.image` in SQL queries against the `users` table.

---

## Key Patterns

### Session validation in API routes
```typescript
import { requireFamily, requireOwnership } from "@/lib/auth";

export async function GET(request: Request) {
  const auth = await requireFamily();
  if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
  const familyId = auth.session!.familyId!;
  // ...
}
```

### ThemeProvider
```typescript
import { useTheme } from "./ThemeProvider";
const { theme, toggle, setMode } = useTheme();
// theme: "light" | "dark" | mode: "light" | "dark" | "system" | "time"
```

### Offline Queue
Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.

### Chat Sessions
Stored in localStorage (`tia_chat_sessions`) — shared between home page AI card and `/ai` page.
Database tables: `chat_sessions`, `chat_messages`.

---

## Email (Resend)

Transactional emails via Resend. Functions in `src/lib/email.ts`:
- `sendVerificationEmail(email, token, userId)` — account verification
- `sendFamilyInviteEmail({ to, inviterName, familyName, token, role })` — family invite

Emails fall back to console log if `RESEND_API_KEY` is not set (dev mode).

---

## Family Invites

Flow:
1. Settings → Invite → `POST /api/invites` → creates row in `family_invites`, sends email via Resend
2. Invitee clicks `/invite/{token}` link → logs in/signs up
3. `POST /api/invites/accept` → adds to `family_members`, **deletes** the invite row (single-use)
4. `DELETE /api/invites/[id]` — cancel a pending invite

`family_invites` table columns: `id, family_id, email, role, token, expires_at, created_at, display_name, accepted_at`
(Note: `display_name` and `accepted_at` were missing from the original schema — added in migration `0006`)

---

## Growth Page

- `formatAge(birthDate, measurementDate?)` — shows age as "1y 4mo" or "8mo"
- Goals stored in localStorage (`tia_growth_goal_${childId}`)
- Column order: Goals → Latest Reading → Add Form → WHO+Chart → History
- Color-coded percentiles: 🟢 Normal (15–85th) · 🟡 Watch (<15 or >85) · 🔴 Alert (<3 or >97)

---

## Admin Panel

Access at `/admin-login`.

Pages: `/admin`, `/admin/families`, `/admin/users`, `/admin/children`, `/admin/revenue`, `/admin/analytics`, `/admin/support`, `/admin/settings`

Auth pattern: server component layout calls `verifyAdminSession()` → redirects to `/admin-login` if not authenticated. No client-side cookie checks.

---

## Monitoring & Alerting

Alerts go to Telegram (tiaBaby_Bot) via `src/lib/alert.ts` → `sendAlert(level, title, detail?, opts?)`. Best-effort, never throws.

| Signal | Where | How it fires |
|--------|-------|--------------|
| Site/health up-down | **Uptime Kuma** (external, in Dokploy) | Pings `GET /api/healthz` (public, 200/503). Kuma handles flip detection + recovery |
| Backup fail / empty | `POST /api/cron/backup` | Alerts on exception, and warns if the gzipped dump < 1KB. Success is a silent confirmation |
| Error spikes | `/api/cron/monitor` | Rising-edge: errors in last 1h > 5 **and** > 2× the prior hour. Stateless (no re-alert on flat rate) |
| Internal health | `/api/cron/monitor` | DB unreachable, no migrations, missing integration env |
| Visitor digest | `/api/cron/visitor-summary` | Polls Umami REST API (login → stats/metrics/active), posts digest. `?hours=N` window |

**Cron endpoints** all require the `x-cron-secret: $CRON_SECRET` header (same as backup). Schedule in Dokploy:
- `monitor` — hourly
- `visitor-summary` — daily (or `?hours=1` hourly during launch)
- `backup` — daily (existing)

**Test the Telegram wiring:** `GET /api/cron/monitor?test=1` with the cron-secret header sends a test ping.

---

## Data Storage Rules

| Data Type | Storage | API |
|----------|---------|-----|
| Children | Database | `/api/children` |
| Activity Logs | Database | `/api/logs` |
| Vaccinations | Database | `/api/vaccinations` |
| Growth Records | Database | `/api/growth` |
| User Profile | Database | `/api/auth/profile` |
| Parent Avatar | Database (`users.image`) + R2 | `/api/auth/avatar` |
| Baby Photo | Database (`children.image_url`) + R2 | `/api/children/[id]` |
| Memories/Photos | Database + R2 | `/api/upload` |
| Auth Session | Database + Cookie | `/api/auth/signin` |
| Theme | localStorage | `tia_theme` |
| Growth Goals | localStorage | `tia_growth_goal_${childId}` |
| Custom Folders | localStorage | `tia_custom_folders` |
| Offline Queue | localStorage | `tia_offline_queue` |

**NEVER use localStorage for:** authentication tokens, family_id, or any data that should persist across devices.

---

## Environment Variables

Set in `.env.local` for development, Dokploy dashboard for production.

| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | ✅ | PostgreSQL connection |
| `DATABASE_URL_SUPERUSER` | ✅ | Superuser connection (migrations only) |
| `LITELLM_BASE_URL` | ✅ | AI gateway URL |
| `LITELLM_API_KEY` | ✅ | AI API key |
| `R2_ACCOUNT_ID` | ✅ | Cloudflare R2 account ID |
| `R2_ACCESS_KEY_ID` | ✅ | R2 access key |
| `R2_SECRET_ACCESS_KEY` | ✅ | R2 secret key |
| `R2_BUCKET_NAME` | ✅ | R2 bucket name (e.g. "tia") |
| `R2_PUBLIC_URL` | ✅ | Public R2 URL |
| `RESEND_API_KEY` | ✅ | Resend API key for transactional email |
| `EMAIL_FROM` | ✅ | Sender address (e.g. `Tia <tia@manohargupta.com>`) |
| `NEXT_PUBLIC_APP_URL` | ✅ | Full app URL (e.g. `https://tia.manohargupta.com`) |
| `CRON_SECRET` | ✅ | Secret for cron endpoints (backup, monitor, visitor-summary) — sent as `x-cron-secret` header |
| `TELEGRAM_BOT_TOKEN` | ✅ | tiaBaby_Bot token from @BotFather — operational alerts |
| `TELEGRAM_CHAT_ID` | ✅ | Chat/group/channel id alerts post to (see `src/lib/alert.ts` header for how to get it) |
| `UMAMI_BASE_URL` | — | Umami instance (default `https://analytics.manohargupta.com`) |
| `UMAMI_USERNAME` | ✅ | Umami login — for the visitor-summary cron |
| `UMAMI_PASSWORD` | ✅ | Umami password |
| `UMAMI_WEBSITE_ID` | — | Umami website id (default Tia's id) |

---

## R2 Storage (Cloudflare)

- Account ID: `e71f22a2f8614fb3ba6d9b28a264d8ce`
- S3 Endpoint: `https://e71f22a2f8614fb3ba6d9b28a264d8ce.r2.cloudflarestorage.com`
- Public URL: `https://pub-37a76fd657c94d1dbc521a109c087a11.r2.dev` (no bucket name in path!)

Key learnings:
1. S3 API endpoint does NOT include bucket name
2. Public URL does NOT include `/tia/` bucket path
3. Direct browser PUT to presigned URL = CORS blocked → always proxy via `/api/upload`

---

## Known Issues & Fixes Applied

### Turbopack Parsing Issue
Some files cause "Unterminated regexp literal" with Turbopack. Fix: `pnpm build -- --webpack`
Avoid patterns like `name: "TT/Td"` in arrays (the `/` can be misread as regex).

### users.image vs users.avatar_url
The `users` table uses `image` for the profile photo column. Using `avatar_url` in SQL will throw
"column does not exist". `avatar_url` exists only on `member_profiles`.

### family_invites missing columns
Original schema was missing `display_name` and `accepted_at`. Added in `drizzle/0006_family_invites_missing_cols.sql`.
Hot-fixed via `/api/debug-migration` POST.

### Admin auth (httpOnly cookie)
`tia_admin_session` is httpOnly — `document.cookie` can never read it. Admin pages use a **server component layout**
that calls `verifyAdminSession()` server-side. Client pages use `credentials: 'include'` on fetches; no Bearer tokens.
