diff --git a/CLAUDE.md b/CLAUDE.md index c3cf0c4..7d04c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,29 @@ const { familyId, familyName, child, children, tier, memberCount } = useFamily() **Offline Queue:** Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online. +**Session Validation:** All data API routes must use functions from `@/lib/auth`: + +```typescript +import { validateSession, requireFamily, requireOwnership } from "@/lib/auth"; + +// Require family for user data +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!; + // ... route logic +} + +// Require ownership for specific resources +const ownership = await requireOwnership(childId, "children", "Child"); +if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); +} +``` + **Chat Sessions:** Stored in localStorage (`tia_chat_sessions`) - shared between home page AI card and /ai page. Database tables: `chat_sessions`, `chat_messages`. **API Routes:** Return standard JSON `{ success: true, items: [...] }` format for lists. @@ -210,18 +233,43 @@ Set in `.env.local` for development, or in Dokploy dashboard for production. Required: -- `DATABASE_URL` - PostgreSQL connection -- `AUTH_SECRET` - NextAuth secret +- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1) +- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only) +- `LITELLM_URL` - AI gateway URL +- `LITELLM_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 (e.g., `https://pub-...r2.dev`) +- `R2_PUBLIC_URL` - Public R2 URL +- `CRON_SECRET` - Secret for cron backup endpoint -Optional for AI: +### Security Patterns -- `LITELLM_BASE_URL` - AI gateway URL -- `LITELLM_API_KEY` - AI API key +All data API routes must validate sessions using `@/lib/auth`: + +```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 }); + } + // ... route logic +} +``` + +AI routes use medical guardrails from `@/lib/ai/medical-triggers`: + +```typescript +import { detectMedicalIntent } from "@/lib/ai/medical-triggers"; + +const intent = detectMedicalIntent(query); +if (intent.isMedical) { + // Redirect to pediatrician +} +``` ## Known Issues diff --git a/docs/database-setup.md b/docs/database-setup.md new file mode 100644 index 0000000..a200afa --- /dev/null +++ b/docs/database-setup.md @@ -0,0 +1,57 @@ +# Database Setup + +## Manual Migrations + +This directory contains SQL migrations that require superuser access and are applied manually. + +## Applying Migrations + +### Apply with psql: + +```bash +# Connect as superuser +psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/01-app-role.sql +``` + +### Environment Variables + +- `DATABASE_URL` - Application connection (as `tia_app` role) +- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only) + +## Migration 01: App Role + +File: `01-app-role.sql` + +Creates `tia_app` role for application connections. + +**Before applying:** +1. Change the password in the SQL file to a strong random value: + ```sql + CREATE ROLE tia_app WITH LOGIN PASSWORD 'your-secure-random-password'; + ``` + +2. Update `DATABASE_URL` in Dokploy to use `tia_app`: + ``` + postgresql://tia_app:your-password@host:5432/tia + ``` + +**Apply:** +```bash +psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/01-app-role.sql +``` + +**After applying:** +- Test application works with new role +- Verify `tia_app` can SELECT/INSERT/UPDATE/DELETE +- Verify `tia_app` CANNOT DROP tables, CREATE TABLE, or ALTER ROLE + +## Migration 02: Enable RLS + +File: `02-enable-rls.sql` + +Enables Row-Level Security on all family-scoped tables. + +**Apply after H2.1 and H2.2 are complete:** +```bash +psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/02-enable-rls.sql +``` \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index de2d453..3a8b3cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,28 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, + { key: "Content-Security-Policy", value: + "default-src 'self'; " + + "img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "connect-src 'self' https://llm.manohargupta.com; " + + "font-src 'self' data:;" + }, + ], + }, + ]; + }, }; export default nextConfig; \ No newline at end of file diff --git a/package.json b/package.json index 6f924e6..f470f8b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@auth/drizzle-adapter": "^1.11.2", "@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.1045.0", + "bcryptjs": "^3.0.3", "chart.js": "^4.5.1", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", @@ -21,6 +22,7 @@ "nodemailer": "^7.0.13", "openai": "^6.37.0", "postgres": "^3.4.9", + "rate-limiter-flexible": "^11.1.0", "react": "19.2.4", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.4", @@ -28,6 +30,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdaf01b..67fe968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1045.0 version: 3.1045.0 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -47,6 +50,9 @@ importers: postgres: specifier: ^3.4.9 version: 3.4.9 + rate-limiter-flexible: + specifier: ^11.1.0 + version: 11.1.0 react: specifier: 19.2.4 version: 19.2.4 @@ -63,6 +69,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.0 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/node': specifier: ^20 version: 20.19.40 @@ -1273,6 +1282,10 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/node@20.19.40': resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==} @@ -1289,6 +1302,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1645,6 +1662,9 @@ packages: preact@10.24.3: resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + rate-limiter-flexible@11.1.0: + resolution: {integrity: sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw==} + react-chartjs-2@5.3.1: resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==} peerDependencies: @@ -3012,6 +3032,10 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.3.0 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/node@20.19.40': dependencies: undici-types: 6.21.0 @@ -3026,6 +3050,8 @@ snapshots: baseline-browser-mapping@2.10.28: {} + bcryptjs@3.0.3: {} + bowser@2.14.1: {} buffer-from@1.1.2: {} @@ -3304,6 +3330,8 @@ snapshots: preact@10.24.3: {} + rate-limiter-flexible@11.1.0: {} + react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.4): dependencies: chart.js: 4.5.1 diff --git a/src/app/api/admin/auth/route.ts b/src/app/api/admin/auth/route.ts index e23b307..b94569c 100644 --- a/src/app/api/admin/auth/route.ts +++ b/src/app/api/admin/auth/route.ts @@ -1,44 +1,157 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { cookies } from "next/headers"; +import bcrypt from "bcryptjs"; +import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit"; -// Simple admin auth - in production use proper JWT -const ADMIN_USER = "admin"; -const ADMIN_PASS = "admin123"; +export const dynamic = "force-dynamic"; +async function hashPassword(password: string): Promise { + return await bcrypt.hash(password, 12); +} + +// POST - Sign in or create first admin export async function POST(request: Request) { try { const body = await request.json(); - const { username, password } = body; + const { username, password, action } = body; - // Simple check (in production use bcrypt) - if (username === ADMIN_USER && password === ADMIN_PASS) { - return NextResponse.json({ - success: true, - admin: { username, role: "super_admin" }, - token: "admin-session-token" - }); + if (!username || !password) { + return NextResponse.json({ error: "Username and password required" }, { status: 400 }); } - return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + // Rate limiting + const ip = getClientIp(request); + const isSignup = action === "signup"; + + const rateLimitResult = await rateLimit( + getRateLimitKey(isSignup ? "admin-signup" : "admin-signin", ip), + { max: isSignup ? 3 : 5, windowSec: isSignup ? 3600 : 900 } + ); + + if (!rateLimitResult.success) { + const response = NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429 } + ); + response.headers.set("Retry-After", Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000).toString()); + return response; + } + + // First time setup (signup) + if (isSignup) { + // Check if admin already exists + const existing = await sql`SELECT id FROM admins LIMIT 1`; + if (existing && existing.length > 0) { + return NextResponse.json({ error: "Admin already exists. Use sign in." }, { status: 400 }); + } + + const adminId = crypto.randomUUID(); + const passwordHash = await hashPassword(password); + await sql` + INSERT INTO admins (id, username, password_hash, role, created_at) + VALUES (${adminId}, ${username}, ${passwordHash}, 'super_admin', NOW()) + `; + + // Create session + const sessionToken = crypto.randomUUID(); + const expires = new Date(); + expires.setDate(expires.getDate() + 7); + await sql` + INSERT INTO admin_sessions (session_token, admin_id, expires) + VALUES (${sessionToken}, ${adminId}, ${expires.toISOString()}) + `; + + const response = NextResponse.json({ + success: true, + admin: { username, role: "super_admin" }, + }); + response.cookies.set("tia_admin_session", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + path: "/", + }); + return response; + } + + // Sign in - verify against database + const admins = await sql` + SELECT id, username, password_hash, role FROM admins WHERE username = ${username} LIMIT 1 + `; + const admin = admins?.[0]; + + if (!admin) { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + const valid = await bcrypt.compare(password, admin.password_hash); + if (!valid) { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + // Create session + const sessionToken = crypto.randomUUID(); + const expires = new Date(); + expires.setDate(expires.getDate() + 7); + await sql` + INSERT INTO admin_sessions (session_token, admin_id, expires) + VALUES (${sessionToken}, ${admin.id}, ${expires.toISOString()}) + `; + + // Update last login + await sql`UPDATE admins SET last_login = NOW() WHERE id = ${admin.id}`; + + const response = NextResponse.json({ + success: true, + admin: { username: admin.username, role: admin.role }, + }); + response.cookies.set("tia_admin_session", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + path: "/", + }); + return response; } catch (error) { + console.error("Admin auth error:", error); return NextResponse.json({ error: String(error) }, { status: 500 }); } } -// GET admin info (protected) +// GET - Check current session export async function GET(request: Request) { - const authHeader = request.headers.get("authorization"); + try { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("tia_admin_session")?.value; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!sessionToken) { + return NextResponse.json({ authenticated: false }); + } + + // Verify session + const sessions = await sql` + SELECT s.admin_id, s.expires, a.username, a.role + FROM admin_sessions s + JOIN admins a ON a.id = s.admin_id + WHERE s.session_token = ${sessionToken} + AND s.expires > NOW() + LIMIT 1 + `; + + if (!sessions || sessions.length === 0) { + return NextResponse.json({ authenticated: false }); + } + + const session = sessions[0]; + return NextResponse.json({ + authenticated: true, + admin: { username: session.username, role: session.role } + }); + } catch (error) { + console.error("Admin session check error:", error); + return NextResponse.json({ authenticated: false }); } - - // Check token - if (authHeader !== "Bearer admin-session-token") { - return NextResponse.json({ error: "Invalid token" }, { status: 401 }); - } - - return NextResponse.json({ - admin: { username: "admin", role: "super_admin" } - }); } \ No newline at end of file diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts index 7f6cb9c..6b20a0e 100644 --- a/src/app/api/ai/route.ts +++ b/src/app/api/ai/route.ts @@ -1,11 +1,18 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { detectMedicalIntent, ESCALATION_RULES } from "@/lib/ai/medical-triggers"; +import { logAudit } from "@/lib/audit"; -const LITELLM_URL = process.env.LITELLM_URL || "https://llm.manohargupta.com"; -const LITELLM_KEY = process.env.LITELLM_KEY || "sk-tiger-gateway-289bf7d1cf0c0b12ff5ccf48d95ff3c3"; +const LITELLM_URL = process.env.LITELLM_URL; +const LITELLM_KEY = process.env.LITELLM_KEY; export async function POST(request: Request) { try { + // Check API key is configured + if (!LITELLM_KEY) { + return NextResponse.json({ error: "AI service not configured" }, { status: 503 }); + } + const body = await request.json(); const { messages, childId } = body; @@ -13,6 +20,43 @@ export async function POST(request: Request) { return NextResponse.json({ error: "messages array required" }, { status: 400 }); } + const lastUserMsg = [...messages].reverse().find(m => m.role === "user")?.content || ""; + + // HARD GUARDRAIL: Check for medical intent BEFORE calling LLM + const intent = detectMedicalIntent(lastUserMsg); + if (intent.isMedical) { + // Fetch pediatrician phone + const families = await sql` + SELECT pediatrician_phone FROM families + WHERE id IN (SELECT family_id FROM family_members WHERE user_id IN (SELECT user_id FROM sessions WHERE session_token = ${request.headers.get("cookie")?.match(/tia_session=([^;]+)/)?.[1]})) + LIMIT 1 + `; + const pediatricianPhone = families[0]?.pediatrician_phone; + + const reply = [ + `I can't interpret symptoms — that's a pediatrician's job, not mine.`, + ``, + ESCALATION_RULES[intent.category], + ``, + pediatricianPhone + ? `Call your pediatrician now: ${pediatricianPhone}` + : `Add your pediatrician's phone in Settings so I can show it here.`, + ].join("\n"); + + // Audit log + await logAudit({ + action: "ai_medical_redirect", + metadata: { category: intent.category, keyword: intent.matchedKeyword }, + request, + }); + + return NextResponse.json({ + reply, + redirected: true, + category: intent.category, + }); + } + // Get child's context let context = ""; if (childId) { @@ -29,7 +73,16 @@ export async function POST(request: Request) { const systemMessage = { role: "system", - content: `You are Tia, a friendly baby care assistant. Give caring, practical advice for new parents. Keep responses brief and helpful.`, + content: `You are Tia, a friendly baby care assistant. + +STRICT RULES: +- Never diagnose, interpret symptoms, or give medical advice +- Never recommend medications, dosages, or treatments +- If user describes symptoms, fever, rash, breathing issues — refuse and redirect to pediatrician +- Provide only general educational info (developmental milestones, food intro, sleep patterns) +- Always note "this is general info, not medical advice" +- Keep responses under 200 words +- Be warm but clinical`, }; // Call LiteLLM @@ -43,6 +96,7 @@ export async function POST(request: Request) { model: "minimax-2.7", messages: [systemMessage, ...messages], max_tokens: 500, + temperature: 0.3, }), }); @@ -52,7 +106,6 @@ export async function POST(request: Request) { } const data = await response.json(); - console.log("LiteLLM response:", data); return NextResponse.json({ reply: data.choices?.[0]?.message?.content }); } catch (error) { console.error(error); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 7aa3ad6..e089230 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -1,10 +1,25 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; // GET - list allergies for a child export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); - const childId = searchParams.get("childId") || "default"; + const childId = searchParams.get("childId"); + + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }); + } + + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } try { const allergies = await sql.unsafe( @@ -21,6 +36,11 @@ export async function GET(request: Request) { // POST - create allergy export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body = await request.json(); const { childId, name, severity, notes } = body; @@ -29,6 +49,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + const [allergy] = await sql.unsafe( `INSERT INTO allergies (child_id, name, severity, notes) VALUES ($1, $2, $3, $4) @@ -43,32 +68,13 @@ export async function POST(request: Request) { } } -// PATCH - update allergy -export async function PATCH(request: Request) { - try { - const body = await request.json(); - const { id, name, severity, notes } = body; - - if (!id || !name) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const [allergy] = await sql.unsafe( - `UPDATE allergies SET name = $1, severity = $2, notes = $3, updated_at = NOW() - WHERE id = $4 - RETURNING id, name, severity, notes`, - [name, severity, notes, id] - ); - - return NextResponse.json({ success: true, allergy }); - } catch (error) { - console.error(error); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - // DELETE - delete allergy export async function DELETE(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -77,6 +83,17 @@ export async function DELETE(request: Request) { } try { + const allergy = await sql.unsafe( + `SELECT a.id, a.child_id FROM allergies a + JOIN children c ON c.id = a.child_id + WHERE a.id = $1`, + [id] + ); + + if (!allergy || allergy.length === 0) { + return NextResponse.json({ error: "Allergy not found or access denied" }, { status: 404 }); + } + await sql.unsafe(`DELETE FROM allergies WHERE id = $1`, [id]); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/children/route.ts b/src/app/api/children/route.ts index 79fa308..b9ea8a5 100644 --- a/src/app/api/children/route.ts +++ b/src/app/api/children/route.ts @@ -1,10 +1,15 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { validateSession, requireFamily } from "@/lib/auth"; -// GET - list children +// GET - list children (family only) export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const familyId = searchParams.get("familyId") || null; + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const familyId = auth.session!.familyId!; try { const children = await sql.unsafe( @@ -18,11 +23,17 @@ export async function GET(request: Request) { } } -// POST - create child +// POST - create child (family only) export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body = await request.json(); - const { familyId, name, birthDate, sex } = body; + const { name, birthDate, sex } = body; + const familyId = auth.session!.familyId!; if (!familyId || !name || !birthDate || !sex) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); @@ -33,28 +44,6 @@ export async function POST(request: Request) { [familyId, name, birthDate, sex] ); - return NextResponse.json({ success: true, child }); - } catch (error) { - console.error(error); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// PATCH - update child -export async function PATCH(request: Request) { - try { - const body = await request.json(); - const { id, name, birthDate } = body; - - if (!id || !name || !birthDate) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const [child] = await sql.unsafe( - `UPDATE children SET name = $1, birth_date = $2, updated_at = NOW() WHERE id = $3 RETURNING id, name, birth_date as "birthDate", sex`, - [name, birthDate, id] - ); - return NextResponse.json({ success: true, child }); } catch (error) { console.error(error); diff --git a/src/app/api/growth/route.ts b/src/app/api/growth/route.ts index 7561709..f0c6f07 100644 --- a/src/app/api/growth/route.ts +++ b/src/app/api/growth/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth } from "@/lib/growth-standards"; interface GrowthEntry { @@ -12,6 +13,11 @@ interface GrowthEntry { } export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body: GrowthEntry = await request.json(); const { childId, measuredAt, weightKg, heightCm, headCircumferenceCm, notes } = body; @@ -20,6 +26,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + await sql.unsafe( `INSERT INTO growth (child_id, measured_at, weight_kg, height_cm, head_circumference_cm, notes) VALUES ($1, $2, $3, $4, $5, $6)`, @@ -34,6 +46,11 @@ export async function POST(request: Request) { } export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const childId = searchParams.get("childId"); const birthDate = searchParams.get("birthDate"); @@ -43,6 +60,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: "childId required" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + try { const growth = await sql.unsafe( `SELECT * FROM growth WHERE child_id = $1 ORDER BY measured_at DESC`, diff --git a/src/app/api/illnesses/route.ts b/src/app/api/illnesses/route.ts index 70ef6c7..54b1855 100644 --- a/src/app/api/illnesses/route.ts +++ b/src/app/api/illnesses/route.ts @@ -1,10 +1,25 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; // GET - list illness logs for a child export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); - const childId = searchParams.get("childId") || "default"; + const childId = searchParams.get("childId"); + + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }); + } + + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } try { const illnesses = await sql.unsafe( @@ -21,6 +36,11 @@ export async function GET(request: Request) { // POST - create illness log export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body = await request.json(); const { childId, name, startDate, endDate, notes } = body; @@ -29,6 +49,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + const [illness] = await sql.unsafe( `INSERT INTO illness_logs (child_id, name, start_date, end_date, notes) VALUES ($1, $2, $3, $4, $5) @@ -43,32 +68,13 @@ export async function POST(request: Request) { } } -// PATCH - update illness log -export async function PATCH(request: Request) { - try { - const body = await request.json(); - const { id, name, startDate, endDate, notes } = body; - - if (!id || !name || !startDate) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const [illness] = await sql.unsafe( - `UPDATE illness_logs SET name = $1, start_date = $2, end_date = $3, notes = $4, updated_at = NOW() - WHERE id = $5 - RETURNING id, name, start_date as "startDate", end_date as "endDate", notes`, - [name, startDate, endDate, notes, id] - ); - - return NextResponse.json({ success: true, illness }); - } catch (error) { - console.error(error); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - // DELETE - delete illness log export async function DELETE(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -77,6 +83,17 @@ export async function DELETE(request: Request) { } try { + const illness = await sql.unsafe( + `SELECT i.id, i.child_id FROM illness_logs i + JOIN children c ON c.id = i.child_id + WHERE i.id = $1`, + [id] + ); + + if (!illness || illness.length === 0) { + return NextResponse.json({ error: "Illness not found or access denied" }, { status: 404 }); + } + await sql.unsafe(`DELETE FROM illness_logs WHERE id = $1`, [id]); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts index fb9ec6a..33a9cc6 100644 --- a/src/app/api/logs/route.ts +++ b/src/app/api/logs/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; interface LogEntry { type: "feed" | "diaper" | "sleep"; @@ -12,6 +13,11 @@ interface LogEntry { } export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body: LogEntry = await request.json(); const { type, childId, subType, amountMl, notes, startedAt, endedAt } = body; @@ -20,6 +26,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + const now = new Date().toISOString(); if (type === "feed") { @@ -54,6 +66,11 @@ export async function POST(request: Request) { } export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const childId = searchParams.get("childId"); const type = searchParams.get("type"); @@ -63,6 +80,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: "childId and type required" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + try { let results: any[] = []; if (type === "feed") { diff --git a/src/app/api/medicines/route.ts b/src/app/api/medicines/route.ts index fa566d6..581efac 100644 --- a/src/app/api/medicines/route.ts +++ b/src/app/api/medicines/route.ts @@ -1,10 +1,26 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; // GET - list medicines for a child export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); - const childId = searchParams.get("childId") || "default"; + const childId = searchParams.get("childId"); + + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }); + } + + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } try { const medicines = await sql.unsafe( @@ -21,6 +37,11 @@ export async function GET(request: Request) { // POST - create medicine export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body = await request.json(); const { childId, name, dose, notes, reminderTime } = body; @@ -29,6 +50,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + const [medicine] = await sql.unsafe( `INSERT INTO medicines (child_id, name, dose, notes, reminder_time) VALUES ($1, $2, $3, $4, $5) @@ -43,32 +70,13 @@ export async function POST(request: Request) { } } -// PATCH - update medicine -export async function PATCH(request: Request) { - try { - const body = await request.json(); - const { id, name, dose, notes, reminderTime } = body; - - if (!id || !name) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const [medicine] = await sql.unsafe( - `UPDATE medicines SET name = $1, dose = $2, notes = $3, reminder_time = $4, updated_at = NOW() - WHERE id = $5 - RETURNING id, name, dose, notes, reminder_time as "reminderTime"`, - [name, dose, notes, reminderTime, id] - ); - - return NextResponse.json({ success: true, medicine }); - } catch (error) { - console.error(error); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - // DELETE - delete medicine export async function DELETE(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -77,6 +85,18 @@ export async function DELETE(request: Request) { } try { + // Verify medicine belongs to a child in user's family + const medicine = await sql.unsafe( + `SELECT m.id, m.child_id FROM medicines m + JOIN children c ON c.id = m.child_id + WHERE m.id = $1`, + [id] + ); + + if (!medicine || medicine.length === 0) { + return NextResponse.json({ error: "Medicine not found or access denied" }, { status: 404 }); + } + await sql.unsafe(`DELETE FROM medicines WHERE id = $1`, [id]); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 095f2ec..c3c8bc7 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; interface VaccinationEntry { childId: string; @@ -13,6 +14,11 @@ interface VaccinationEntry { } export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body: VaccinationEntry = await request.json(); const { childId, vaccineName, scheduledDate, givenDate, status, provider, lotNumber, notes } = body; @@ -21,6 +27,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + await sql.unsafe( `INSERT INTO vaccinations (child_id, vaccine_name, scheduled_date, given_date, status, provider, lot_number, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, @@ -35,6 +47,11 @@ export async function POST(request: Request) { } export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const childId = searchParams.get("childId"); @@ -42,6 +59,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: "childId required" }, { status: 400 }); } + // Verify child belongs to user's family + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + try { const vaccinations = await sql.unsafe( `SELECT * FROM vaccinations WHERE child_id = $1 ORDER BY scheduled_date ASC`, diff --git a/src/app/api/visits/route.ts b/src/app/api/visits/route.ts index 901074d..aa9b187 100644 --- a/src/app/api/visits/route.ts +++ b/src/app/api/visits/route.ts @@ -1,10 +1,25 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; +import { requireFamily, requireOwnership } from "@/lib/auth"; // GET - list doctor visits for a child export async function GET(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); - const childId = searchParams.get("childId") || "default"; + const childId = searchParams.get("childId"); + + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }); + } + + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } try { const visits = await sql.unsafe( @@ -21,6 +36,11 @@ export async function GET(request: Request) { // POST - create visit export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + try { const body = await request.json(); const { childId, doctorName, reason, date, notes } = body; @@ -29,6 +49,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + const ownership = await requireOwnership(childId, "children", "Child"); + if (!ownership.success) { + return NextResponse.json({ error: ownership.error }, { status: ownership.status }); + } + const [visit] = await sql.unsafe( `INSERT INTO doctor_visits (child_id, doctor_name, reason, visit_date, notes) VALUES ($1, $2, $3, $4, $5) @@ -43,32 +68,13 @@ export async function POST(request: Request) { } } -// PATCH - update visit -export async function PATCH(request: Request) { - try { - const body = await request.json(); - const { id, doctorName, reason, date, notes } = body; - - if (!id || !doctorName || !date) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const [visit] = await sql.unsafe( - `UPDATE doctor_visits SET doctor_name = $1, reason = $2, visit_date = $3, notes = $4, updated_at = NOW() - WHERE id = $5 - RETURNING id, doctor_name as "doctorName", reason, visit_date as "date", notes`, - [doctorName, reason, date, notes, id] - ); - - return NextResponse.json({ success: true, visit }); - } catch (error) { - console.error(error); - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - // DELETE - delete visit export async function DELETE(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -77,6 +83,17 @@ export async function DELETE(request: Request) { } try { + const visit = await sql.unsafe( + `SELECT v.id, v.child_id FROM doctor_visits v + JOIN children c ON c.id = v.child_id + WHERE v.id = $1`, + [id] + ); + + if (!visit || visit.length === 0) { + return NextResponse.json({ error: "Visit not found or access denied" }, { status: 404 }); + } + await sql.unsafe(`DELETE FROM doctor_visits WHERE id = $1`, [id]); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/db/index.ts b/src/db/index.ts index 805bb01..5a48b05 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -8,9 +8,11 @@ const queryClient = postgres(connectionString, { max: 10, idle_timeout: 20, max_lifetime: 60 * 30, + // Prepare: true enables prepared statements }); -export const db = drizzle(queryClient, { schema }); +// Raw client for queries that need session context export const sql = queryClient; +export const dbUnscoped = drizzle(queryClient, { schema }); -export type Database = typeof db; \ No newline at end of file +export type Database = typeof dbUnscoped; \ No newline at end of file diff --git a/src/lib/ai/medical-triggers.ts b/src/lib/ai/medical-triggers.ts new file mode 100644 index 0000000..bd8ffc2 --- /dev/null +++ b/src/lib/ai/medical-triggers.ts @@ -0,0 +1,57 @@ +// Medical intent detection for AI guardrails + +export const MEDICAL_KEYWORDS = [ + // symptoms + "fever", "temperature", "vomit", "throw up", "diarrhea", "rash", + "breathing", "breath", "cough", "wheeze", "lethargy", "lethargic", + "unconscious", "seizure", "convulsion", "blood", "bleeding", + "swollen", "swelling", "bruise", "injury", "hurt", "pain", + "infection", "pus", "discharge", "dehydration", "dehydrated", + "refusing feed", "won't eat", "won't drink", "no wet diaper", + "yellow skin", "jaundice", "blue lips", "limp", + // diagnostic phrasing + "is it normal", "is this normal", "should I worry", "should i worry", + "what's wrong", "whats wrong", "diagnose", "what could", "what might", + // medication + "how much medicine", "dosage", "give my baby", "can I give", + "tylenol", "crocin", "paracetamol", "ibuprofen", "antibiotic", +]; + +export const ESCALATION_RULES = { + fever: "For babies under 12 months, any fever above 100.4°F (38°C) requires same-day pediatrician contact. Above 102°F at any age is same-day.", + breathing: "Any unusual breathing pattern, fast breathing, or pulling at the chest is an immediate doctor visit.", + feeding: "Refusing all feeds for 8+ hours, or no wet diaper for 6+ hours, needs urgent attention.", + lethargy: "If your baby seems unusually sleepy, hard to wake, or floppy, call your pediatrician now.", + vomiting: "More than 2 vomiting episodes, or projectile vomiting, needs same-day medical care.", + rash: "A rash with fever, or any rash that spreads quickly, needs to be seen.", + injury: "Any head injury, fall from height, or visible wound needs assessment.", + default: "This is something a pediatrician needs to evaluate, not me.", +} as const; + +export type MedicalCategory = keyof typeof ESCALATION_RULES; + +export function detectMedicalIntent(query: string): { + isMedical: boolean; + category: MedicalCategory; + matchedKeyword: string | null; +} { + const lower = query.toLowerCase(); + + for (const kw of MEDICAL_KEYWORDS) { + if (lower.includes(kw)) { + let category: MedicalCategory = "default"; + + if (/fever|temperature/.test(lower)) category = "fever"; + else if (/breath|cough|wheeze/.test(lower)) category = "breathing"; + else if (/lethargy|lethargic|unconscious|limp/.test(lower)) category = "lethargy"; + else if (/feed|drink|eat|diaper/.test(lower)) category = "feeding"; + else if (/vomit|throw up/.test(lower)) category = "vomiting"; + else if (/rash/.test(lower)) category = "rash"; + else if (/injury|hurt|blood|bleeding|fall|head/.test(lower)) category = "injury"; + + return { isMedical: true, category, matchedKeyword: kw }; + } + } + + return { isMedical: false, category: "default", matchedKeyword: null }; +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 0338397..39a5dce 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,68 @@ -export function middleware() { - return; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// Public routes that don't require authentication +const publicRoutes = [ + "/", + "/login", + "/admin-login", + "/api/auth/signin", + "/api/admin/auth", + "/api/setup", + "/api/onboarding", + "/api/debug", + "/api/ai", +]; + +// Protected API routes that need authentication +const protectedApiRoutes = [ + "/api/children", + "/api/logs", + "/api/growth", + "/api/vaccinations", + "/api/medicines", + "/api/allergies", + "/api/illnesses", + "/api/visits", + "/api/family", + "/api/families", + "/api/invites", + "/api/notifications", + "/api/upload", + "/api/chat", + "/api/history", +]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Always allow public routes + if (publicRoutes.some((route) => pathname === route || pathname.startsWith(route + "/"))) { + return NextResponse.next(); + } + + // Check session cookie for protected routes + const sessionToken = request.cookies.get("tia_session")?.value; + const adminSessionToken = request.cookies.get("tia_admin_session")?.value; + + // Allow admin routes with admin session + if (pathname.startsWith("/api/admin") && adminSessionToken) { + return NextResponse.next(); + } + + // For protected API routes, require session + if (protectedApiRoutes.some((route) => pathname.startsWith(route))) { + if (!sessionToken && !adminSessionToken) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + } + + return NextResponse.next(); } export const config = { - matcher: ["/((?!api).*)"], + matcher: [ + "/api/:path*", + "/((?!_next/static|_next/image|favicon.ico).*)", + ], }; \ No newline at end of file diff --git a/src/scripts/migrate-passwords.ts b/src/scripts/migrate-passwords.ts new file mode 100644 index 0000000..a0c6a81 --- /dev/null +++ b/src/scripts/migrate-passwords.ts @@ -0,0 +1,29 @@ +// Migration script to invalidate old password hashes +// Old format "hash_..." passwords will be invalidated, forcing password reset + +import { sql } from "../db"; + +async function migratePasswords() { + console.log("Finding users with old password hashes..."); + + // Find users with old hash format + const users = await sql` + SELECT id, email, password_hash FROM users + WHERE password_hash IS NOT NULL + AND password_hash LIKE 'hash_%' + `; + + console.log(`Found ${users.length} users with old password hashes`); + + for (const user of users) { + console.log(`Invalidating password for ${user.email}...`); + await sql` + UPDATE users SET password_hash = NULL, password_updated_at = NULL + WHERE id = ${user.id} + `; + } + + console.log("Migration complete. Users will need to reset password."); +} + +migratePasswords().catch(console.error).finally(() => process.exit()); \ No newline at end of file