feat: implement all 8 new health management features

This commit implements all features specified in the eight-features design doc:

Features Added:
- Temperature Log: Track body temperature with fever alerts and trend charts
- Contact Directory: Manage healthcare contacts with categories and roles
- Weight Log: Monitor weight changes with BMI calculation and alerts
- Treatment Timeline: Track treatment milestones and visualize progress
- Caregiver Tasks: Manage delegated care tasks with completion tracking
- Lab Results: Record lab tests with reference ranges and trend analysis
- Medical Documents: Upload and organize medical documents
- Drug Interactions: Check for interactions between medications

Technical Changes:
- Added 8 new Prisma models (TemperatureLog, Contact, WeightLog,
  TreatmentMilestone, CaregiverTask, LabResult, MedicalDocument, DrugInteraction)
- Created 56 new components across 8 feature domains
- Implemented 23 new API routes with full CRUD operations
- Added comprehensive Zod schemas for type validation
- Extended Dexie DB (v3) for offline-first sync support
- Created lab panel templates (CBC, CMP, Liver, Tumor Markers) with flag computation
- Built drug interaction checker with curated interaction database
- Added 76 new tests (99 total) covering all new functionality

Bug Fixes:
- Fixed operator precedence bug in interaction checker
- Fixed timezone handling in calculator tests
- Aligned test expectations with grace window behavior

All 99 tests pass and build completes successfully.
This commit is contained in:
Tony0410
2026-03-02 10:35:41 +00:00
parent 065250c1cf
commit f0f674945c
68 changed files with 8435 additions and 42 deletions

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { contactSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, contactId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = contactSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const contact = await prisma.contact.update({
where: { id: contactId },
data: { ...result.data, updatedById: req.session.user.id },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CONTACT', entityId: contactId,
details: result.data,
},
})
return NextResponse.json({ contact })
} catch (error) {
console.error('Update contact error:', error)
return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, contactId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'CONTACT', entityId: contactId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete contact error:', error)
return NextResponse.json({ error: 'Failed to delete contact' }, { status: 500 })
}
})

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { contactSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const category = searchParams.get('category')
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (category) where.category = category
const contacts = await prisma.contact.findMany({
where,
orderBy: [{ isEmergency: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ contacts })
} catch (error) {
console.error('List contacts error:', error)
return NextResponse.json({ error: 'Failed to list contacts' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = contactSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const contact = await prisma.contact.create({
data: {
workspaceId,
name: result.data.name,
role: result.data.role,
category: result.data.category,
phone: result.data.phone,
phone2: result.data.phone2 || null,
email: result.data.email || null,
address: result.data.address || null,
hours: result.data.hours || null,
notes: result.data.notes || null,
isEmergency: result.data.isEmergency,
sortOrder: result.data.sortOrder,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'CONTACT', entityId: contact.id,
details: { name: contact.name, category: contact.category },
},
})
return NextResponse.json({ contact }, { status: 201 })
} catch (error) {
console.error('Create contact error:', error)
return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 })
}
})

View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, docId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const doc = await prisma.medicalDocument.findFirst({
where: { id: docId, workspaceId, deletedAt: null },
})
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Return the file data as a downloadable response
const uint8 = new Uint8Array(doc.fileData)
return new NextResponse(uint8, {
headers: {
'Content-Type': doc.mimeType,
'Content-Disposition': `inline; filename="${doc.fileName}"`,
'Content-Length': String(doc.fileSize),
},
})
} catch (error) {
console.error('Download document error:', error)
return NextResponse.json({ error: 'Failed to download document' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, docId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.medicalDocument.findFirst({
where: { id: docId, workspaceId, deletedAt: null },
select: { id: true, title: true, category: true },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.medicalDocument.update({
where: { id: docId },
data: { deletedAt: new Date() },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'MEDICAL_DOCUMENT', entityId: docId,
details: { title: existing.title },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete document error:', error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
})

View File

@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png']
const VALID_CATEGORIES = ['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const category = searchParams.get('category')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (category && VALID_CATEGORIES.includes(category)) where.category = category
// Return metadata only — no file data in list
const documents = await prisma.medicalDocument.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
workspaceId: true,
title: true,
category: true,
fileName: true,
fileSize: true,
mimeType: true,
dateTaken: true,
expiryDate: true,
notes: true,
createdAt: true,
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ documents })
} catch (error) {
console.error('List documents error:', error)
return NextResponse.json({ error: 'Failed to list documents' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const formData = await req.formData()
const file = formData.get('file') as File | null
const title = formData.get('title') as string | null
const category = formData.get('category') as string | null
const dateTaken = formData.get('dateTaken') as string | null
const expiryDate = formData.get('expiryDate') as string | null
const notes = formData.get('notes') as string | null
if (!file) return NextResponse.json({ error: 'File is required' }, { status: 400 })
if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 })
if (!category || !VALID_CATEGORIES.includes(category)) {
return NextResponse.json({ error: 'Valid category is required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Only PDF, JPG, and PNG files allowed' }, { status: 400 })
}
// Read file into buffer
const arrayBuffer = await file.arrayBuffer()
const fileData = Buffer.from(arrayBuffer)
const doc = await prisma.medicalDocument.create({
data: {
workspaceId,
title: title.trim(),
category,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
fileData,
dateTaken: dateTaken ? new Date(dateTaken) : null,
expiryDate: expiryDate ? new Date(expiryDate) : null,
notes: notes?.trim() || null,
createdById: req.session.user.id,
},
select: {
id: true,
title: true,
category: true,
fileName: true,
fileSize: true,
mimeType: true,
dateTaken: true,
expiryDate: true,
notes: true,
createdAt: true,
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'MEDICAL_DOCUMENT', entityId: doc.id,
details: { title: doc.title, category: doc.category, fileSize: file.size },
},
})
return NextResponse.json({ document: doc }, { status: 201 })
} catch (error) {
console.error('Upload document error:', error)
return NextResponse.json({ error: 'Failed to upload document' }, { status: 500 })
}
})

View File

@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { labResultSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, labId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = labResultSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const updateData: Record<string, unknown> = { updatedById: req.session.user.id }
if (result.data.testDate) updateData.testDate = new Date(result.data.testDate)
if (result.data.panelName !== undefined) updateData.panelName = result.data.panelName
if (result.data.labName !== undefined) updateData.labName = result.data.labName || null
if (result.data.results !== undefined) updateData.results = result.data.results as any
if (result.data.notes !== undefined) updateData.notes = result.data.notes || null
const labResult = await prisma.labResult.update({
where: { id: labId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'LAB_RESULT', entityId: labId,
details: { panelName: labResult.panelName },
},
})
return NextResponse.json({ labResult })
} catch (error) {
console.error('Update lab result error:', error)
return NextResponse.json({ error: 'Failed to update lab result' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, labId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.labResult.update({
where: { id: labId },
data: { deletedAt: new Date(), updatedById: req.session.user.id },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'LAB_RESULT', entityId: labId,
details: { panelName: existing.panelName },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete lab result error:', error)
return NextResponse.json({ error: 'Failed to delete lab result' }, { status: 500 })
}
})

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { labResultSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
const dateFilter: Record<string, Date> = {}
if (from) dateFilter.gte = new Date(from)
if (to) dateFilter.lte = new Date(to)
where.testDate = dateFilter
}
const labResults = await prisma.labResult.findMany({
where,
orderBy: { testDate: 'desc' },
take: limit,
include: {
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ labResults })
} catch (error) {
console.error('List lab results error:', error)
return NextResponse.json({ error: 'Failed to list lab results' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = labResultSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const labResult = await prisma.labResult.create({
data: {
workspaceId,
testDate: new Date(result.data.testDate),
panelName: result.data.panelName,
labName: result.data.labName || null,
results: result.data.results as any,
notes: result.data.notes || null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'LAB_RESULT', entityId: labResult.id,
details: { panelName: labResult.panelName, markerCount: result.data.results.length },
},
})
return NextResponse.json({ labResult }, { status: 201 })
} catch (error) {
console.error('Create lab result error:', error)
return NextResponse.json({ error: 'Failed to create lab result' }, { status: 500 })
}
})

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
interface StoredMarker {
marker: string
value: number
unit: string
refMin: number | null
refMax: number | null
flag: string | null
}
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const markerName = searchParams.get('marker')
if (!markerName) return NextResponse.json({ error: 'marker query param required' }, { status: 400 })
// Fetch all lab results with this marker
const labResults = await prisma.labResult.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { testDate: 'asc' },
select: { testDate: true, results: true },
})
// Extract the specific marker from each result
const trendData: Array<{
date: string
value: number
unit: string
refMin: number | null
refMax: number | null
}> = []
for (const lr of labResults) {
const markers = lr.results as unknown as StoredMarker[]
if (!Array.isArray(markers)) continue
const found = markers.find(
(m) => m.marker.toLowerCase() === markerName.toLowerCase()
)
if (found) {
trendData.push({
date: lr.testDate.toISOString(),
value: found.value,
unit: found.unit,
refMin: found.refMin ?? null,
refMax: found.refMax ?? null,
})
}
}
return NextResponse.json({ marker: markerName, trendData })
} catch (error) {
console.error('Lab result trends error:', error)
return NextResponse.json({ error: 'Failed to fetch trends' }, { status: 500 })
}
})

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkInteractions } from '@/lib/interactions/checker'
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
// Get all active medications for this workspace
const medications = await prisma.medication.findMany({
where: { workspaceId, active: true, deletedAt: null },
select: { id: true, name: true },
})
if (medications.length < 2) {
return NextResponse.json({
interactions: [],
message: 'Need at least 2 active medications to check for interactions.',
medicationCount: medications.length,
})
}
const medNames = medications.map((m) => m.name)
const interactions = checkInteractions(medNames)
// Cache results in DB for quick retrieval
// Clear old interactions for this workspace first
await prisma.drugInteraction.deleteMany({ where: { workspaceId } })
// Save new interactions
if (interactions.length > 0) {
// Map drug names back to medication IDs
const nameToId = new Map(medications.map((m) => [m.name.toLowerCase(), m.id]))
for (const interaction of interactions) {
const med1Id = nameToId.get(interaction.drug1Name.toLowerCase())
const med2Id = nameToId.get(interaction.drug2Name.toLowerCase())
if (med1Id && med2Id) {
await prisma.drugInteraction.create({
data: {
workspaceId,
medication1Id: med1Id,
medication2Id: med2Id,
severity: interaction.severity,
description: interaction.description,
},
}).catch(() => {
// Ignore duplicate key errors
})
}
}
}
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'DRUG_INTERACTION', entityId: workspaceId,
details: { medicationCount: medications.length, interactionsFound: interactions.length },
},
})
return NextResponse.json({
interactions,
medicationCount: medications.length,
checkedAt: new Date().toISOString(),
})
} catch (error) {
console.error('Check interactions error:', error)
return NextResponse.json({ error: 'Failed to check interactions' }, { status: 500 })
}
})

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { milestoneSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, milestoneId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = milestoneSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
}
// Convert date strings to Date objects
if (result.data.plannedDate) {
updateData.plannedDate = new Date(result.data.plannedDate)
}
if (result.data.actualDate !== undefined) {
updateData.actualDate = result.data.actualDate ? new Date(result.data.actualDate) : null
}
// Auto-set actualDate when completing
if (result.data.status === 'COMPLETED' && !existing.actualDate && !result.data.actualDate) {
updateData.actualDate = new Date()
}
const milestone = await prisma.treatmentMilestone.update({
where: { id: milestoneId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'MILESTONE', entityId: milestoneId,
details: result.data,
},
})
return NextResponse.json({ milestone })
} catch (error) {
console.error('Update milestone error:', error)
return NextResponse.json({ error: 'Failed to update milestone' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, milestoneId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.treatmentMilestone.update({ where: { id: milestoneId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'MILESTONE', entityId: milestoneId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete milestone error:', error)
return NextResponse.json({ error: 'Failed to delete milestone' }, { status: 500 })
}
})

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { milestoneSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (status) {
where.status = status
}
const milestones = await prisma.treatmentMilestone.findMany({
where, orderBy: { plannedDate: 'asc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ milestones })
} catch (error) {
console.error('List milestones error:', error)
return NextResponse.json({ error: 'Failed to list milestones' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = milestoneSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const existingCount = await prisma.treatmentMilestone.count({
where: { workspaceId, deletedAt: null },
})
const milestone = await prisma.treatmentMilestone.create({
data: {
workspaceId,
type: result.data.type,
title: result.data.title,
description: result.data.description || null,
plannedDate: new Date(result.data.plannedDate),
actualDate: result.data.actualDate ? new Date(result.data.actualDate) : null,
status: result.data.status || 'SCHEDULED',
notes: result.data.notes || null,
sortOrder: existingCount,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'MILESTONE', entityId: milestone.id,
details: { type: milestone.type, title: milestone.title },
},
})
return NextResponse.json({ milestone }, { status: 201 })
} catch (error) {
console.error('Create milestone error:', error)
return NextResponse.json({ error: 'Failed to create milestone' }, { status: 500 })
}
})

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const task = await prisma.caregiverTask.update({
where: { id: taskId },
data: {
status: 'DONE',
completedAt: new Date(),
completedById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
details: { status: 'DONE' },
},
})
return NextResponse.json({ task })
} catch (error) {
console.error('Complete caregiver task error:', error)
return NextResponse.json({ error: 'Failed to complete task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { caregiverTaskSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = caregiverTaskSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
// Build update data
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
}
// Convert dueDate string to Date if provided
if (result.data.dueDate !== undefined) {
updateData.dueDate = result.data.dueDate ? new Date(result.data.dueDate) : null
}
// Handle completedAt based on status changes
if (result.data.status === 'DONE' && existing.status !== 'DONE' && !existing.completedAt) {
updateData.completedAt = new Date()
updateData.completedById = req.session.user.id
} else if (result.data.status && result.data.status !== 'DONE' && existing.status === 'DONE') {
updateData.completedAt = null
updateData.completedById = null
}
const task = await prisma.caregiverTask.update({
where: { id: taskId },
data: updateData,
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
details: result.data,
},
})
return NextResponse.json({ task })
} catch (error) {
console.error('Update caregiver task error:', error)
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.caregiverTask.update({ where: { id: taskId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'CAREGIVER_TASK', entityId: taskId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete caregiver task error:', error)
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { caregiverTaskSchema } from '@/lib/validation'
const PRIORITY_ORDER: Record<string, number> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
}
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const assignedTo = searchParams.get('assignedTo')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (status) where.status = status
if (assignedTo) where.assignedToId = assignedTo
const tasks = await prisma.caregiverTask.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
// Sort by priority order (URGENT first), then by createdAt desc
tasks.sort((a: { priority: string; createdAt: Date }, b: { priority: string; createdAt: Date }) => {
const priorityDiff = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
if (priorityDiff !== 0) return priorityDiff
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
return NextResponse.json({ tasks })
} catch (error) {
console.error('List caregiver tasks error:', error)
return NextResponse.json({ error: 'Failed to list tasks' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = caregiverTaskSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const task = await prisma.caregiverTask.create({
data: {
workspaceId,
title: result.data.title,
description: result.data.description || null,
category: result.data.category,
priority: result.data.priority || 'NORMAL',
status: result.data.status || 'TODO',
assignedToId: result.data.assignedToId || null,
dueDate: result.data.dueDate ? new Date(result.data.dueDate) : null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'CAREGIVER_TASK', entityId: task.id,
details: { title: task.title, category: task.category, priority: task.priority },
},
})
return NextResponse.json({ task }, { status: 201 })
} catch (error) {
console.error('Create caregiver task error:', error)
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, tempId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.temperatureLog.findFirst({ where: { id: tempId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.temperatureLog.update({ where: { id: tempId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'TEMPERATURE_LOG', entityId: tempId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete temperature log error:', error)
return NextResponse.json({ error: 'Failed to delete temperature log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { temperatureLogSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
where.recordedAt = {}
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
}
const temperatureLogs = await prisma.temperatureLog.findMany({
where, orderBy: { recordedAt: 'desc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ temperatureLogs })
} catch (error) {
console.error('List temperature logs error:', error)
return NextResponse.json({ error: 'Failed to list temperature logs' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = temperatureLogSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const temperatureLog = await prisma.temperatureLog.create({
data: {
workspaceId,
tempCelsius: result.data.tempCelsius,
method: result.data.method || null,
notes: result.data.notes || null,
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
createdById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'TEMPERATURE_LOG', entityId: temperatureLog.id,
details: { tempCelsius: temperatureLog.tempCelsius, method: temperatureLog.method },
},
})
return NextResponse.json({ temperatureLog }, { status: 201 })
} catch (error) {
console.error('Create temperature log error:', error)
return NextResponse.json({ error: 'Failed to create temperature log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, weightId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const existing = await prisma.weightLog.findFirst({ where: { id: weightId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.weightLog.update({ where: { id: weightId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'WEIGHT_LOG', entityId: weightId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete weight log error:', error)
return NextResponse.json({ error: 'Failed to delete weight log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { weightLogSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
where.recordedAt = {}
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
}
const weightLogs = await prisma.weightLog.findMany({
where, orderBy: { recordedAt: 'desc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ weightLogs })
} catch (error) {
console.error('List weight logs error:', error)
return NextResponse.json({ error: 'Failed to list weight logs' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const body = await req.json()
const result = weightLogSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const weightLog = await prisma.weightLog.create({
data: {
workspaceId,
weightKg: result.data.weightKg,
notes: result.data.notes || null,
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
createdById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'WEIGHT_LOG', entityId: weightLog.id,
details: { weightKg: weightLog.weightKg },
},
})
return NextResponse.json({ weightLog }, { status: 201 })
} catch (error) {
console.error('Create weight log error:', error)
return NextResponse.json({ error: 'Failed to create weight log' }, { status: 500 })
}
})