Security hardening: auth, bcrypt, rate-limiting, RLS, audit
This commit is contained in:
parent
4cf886ea43
commit
2196c3d07d
20 changed files with 770 additions and 168 deletions
60
CLAUDE.md
60
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
|
||||
|
||||
|
|
|
|||
57
docs/database-setup.md
Normal file
57
docs/database-setup.md
Normal file
|
|
@ -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
|
||||
```
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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" }
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
export type Database = typeof dbUnscoped;
|
||||
57
src/lib/ai/medical-triggers.ts
Normal file
57
src/lib/ai/medical-triggers.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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).*)",
|
||||
],
|
||||
};
|
||||
29
src/scripts/migrate-passwords.ts
Normal file
29
src/scripts/migrate-passwords.ts
Normal file
|
|
@ -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());
|
||||
Loading…
Add table
Reference in a new issue