tia/CLAUDE.md
Mannu ab937c4e9d feat: Telegram alerting + public health probe + Umami visitor digest
Launch-critical monitoring wiring — alerts go to tiaBaby_Bot via Telegram.

- src/lib/alert.ts: sendAlert(level, title, detail?, {fields, silent}) — HTML
  formatted, IST timestamped, best-effort (never throws). Env: TELEGRAM_BOT_TOKEN,
  TELEGRAM_CHAT_ID
- GET /api/healthz: public, no-auth liveness probe (200 ok / 503 down) for
  Uptime Kuma + Dokploy healthcheck. No sensitive detail
- cron/backup: alert on failure (fatal), warn if dump < 1KB (empty), silent
  success confirmation with file + size
- cron/monitor: error-spike rising-edge detection (last 1h > 5 and > 2x prior
  hour — stateless, no re-alert on flat rate), DB/migrations/integration checks.
  ?test=1 sends a Telegram test ping
- cron/visitor-summary: polls Umami REST API (login -> stats/metrics/active),
  posts visitor digest to Telegram. ?hours=N window (default 24)
- CLAUDE.md: new env vars + Monitoring & Alerting section

Health up/down flip detection is delegated to Uptime Kuma (pings /api/healthz);
this code covers what Kuma can't see from outside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:18 +05:30

340 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (00000002 ≈ 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 (1585th) · 🟡 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.