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

15 KiB
Raw Blame History

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 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 00030010 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:

// 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.

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

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 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.tssendAlert(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.

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.