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