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>
15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
# 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 aftersrc/db/migrate.ts: reads every SQL file indrizzle/and applies any not yet in__drizzle_migrationsdrizzle/meta/_journal.json: ordered list of migration files — must be updated when adding a new SQL file
Adding a new migration
- Create
drizzle/NNNN_description.sqlwith your SQL (useIF NOT EXISTS/ADD COLUMN IF NOT EXISTS) - Add an entry to
drizzle/meta/_journal.jsonwith the nextidxand matchingtag - Push to git → Dokploy auto-applies on next deploy
⚠️ CRITICAL — the
whentimestamp must be greater than every existing entry. Drizzle's migrator only applies a migration when itswhen(folderMillis) is greater than the maxcreated_atalready indrizzle.__drizzle_migrations. It does NOT diff by hash/idx. Migrations0003–0010were hand-added with 2025-erawhenvalues (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: setwhento a currentDate.now()(must be > 1779539431897). If you ever need drizzle to re-apply a skipped one, its SQL must be idempotent (IF NOT EXISTS); note0003_circles.sqlis 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:
// 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)
- User visits
/loginwith email + password POST /api/auth/signinverifies bcrypt hash, creates session insessionstable- Session token stored in httpOnly cookie
tia_session(NOT localStorage!) - All data routes use
requireFamily()from@/lib/auth
Admin auth
- Login at
/admin-login(username:admin, password: from env) - Sets
tia_admin_sessionhttpOnly cookie - Admin layout (
src/app/admin/layout.tsx) is a server component — callsverifyAdminSession()from@/lib/admin-authandredirect('/admin-login')if not authed - Admin sub-pages (
families,users,analytics, etc.) usecredentials: 'include'on all fetches — NO localStorage tokens, NO Bearer headers AdminSidebar.tsxis a separate"use client"component for the interactive sidebar
FamilyProvider
Resolves family from database session on login. Skip for admin routes.
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)
POST /api/[init-endpoint]with{ contentType, filename }→ get{ key, publicUrl }PUT /api/upload?key=...&contentType=...— server proxies the file to R2PATCH /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
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
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 verificationsendFamilyInviteEmail({ 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:
- Settings → Invite →
POST /api/invites→ creates row infamily_invites, sends email via Resend - Invitee clicks
/invite/{token}link → logs in/signs up POST /api/invites/accept→ adds tofamily_members, deletes the invite row (single-use)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— hourlyvisitor-summary— daily (or?hours=1hourly 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:
- S3 API endpoint does NOT include bucket name
- Public URL does NOT include
/tia/bucket path - 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.