Drizzle's migrator applies a migration only when its journal `when` is greater than the max created_at already recorded. Entries 0003-0010 were given 2025-era timestamps (smaller than the 2026 baseline), so drizzle silently skipped them — they only applied via the debug-migration hot-apply endpoint. Document the rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
13 KiB
Markdown
313 lines
13 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## 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 backup endpoint |
|
||
|
||
---
|
||
|
||
## 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.
|