mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
Features added: - Emergency Info Card: Full-screen emergency view with patient info - Refill Tracker: Track pill counts with auto-decrement on dose - Activity Feed: View caregiver activity with filtering - Symptom Tracker: Log symptoms with severity and offline sync - Print Views: Daily meds, appointments, doctor visit summaries - iCal Export: Calendar subscription for appointments - PDF Export: Medical summary for doctor visits - Calendar View: Monthly calendar for appointments - Appointment Preparation: Checklist for upcoming appointments - Medication Reminders: PWA push notifications with quiet hours Bug fixes: - Fix invite workflow: Register/login now properly redirect back - Add undo for doctor questions (can unmark "asked" questions) - Fix API route type annotations for Next.js 14 compatibility - Add Suspense boundary for useSearchParams in login/register Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
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 { syncQuerySchema, syncOpsSchema } from '@/lib/validation'
|
|
|
|
// GET /api/sync - Get changes since cursor
|
|
export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
|
try {
|
|
const { searchParams } = new URL(req.url)
|
|
const result = syncQuerySchema.safeParse({
|
|
workspaceId: searchParams.get('workspaceId'),
|
|
since: searchParams.get('since'),
|
|
})
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid input', details: result.error.flatten() },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const { workspaceId, since = 0 } = result.data
|
|
|
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
|
if (!access) {
|
|
return NextResponse.json(
|
|
{ error: 'Access denied' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
|
|
const sinceDate = new Date(since)
|
|
|
|
// Fetch all changed entities
|
|
const [appointments, medications, notes, doseLogs, symptoms, workspace] = await Promise.all([
|
|
prisma.appointment.findMany({
|
|
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
|
include: {
|
|
createdBy: { select: { id: true, name: true } },
|
|
updatedBy: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
prisma.medication.findMany({
|
|
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
|
include: {
|
|
createdBy: { select: { id: true, name: true } },
|
|
updatedBy: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
prisma.note.findMany({
|
|
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
|
include: {
|
|
createdBy: { select: { id: true, name: true } },
|
|
updatedBy: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
prisma.doseLog.findMany({
|
|
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
|
include: {
|
|
medication: { select: { id: true, name: true } },
|
|
loggedBy: { select: { id: true, name: true } },
|
|
undoneBy: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
prisma.symptom.findMany({
|
|
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
|
include: {
|
|
createdBy: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
prisma.workspace.findUnique({
|
|
where: { id: workspaceId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
clinicPhone: true,
|
|
emergencyPhone: true,
|
|
quietHoursStart: true,
|
|
quietHoursEnd: true,
|
|
largeTextMode: true,
|
|
updatedAt: true,
|
|
// Emergency info fields
|
|
patientName: true,
|
|
patientDOB: true,
|
|
bloodType: true,
|
|
allergies: true,
|
|
medicalConditions: true,
|
|
primaryPhysician: true,
|
|
physicianPhone: true,
|
|
},
|
|
}),
|
|
])
|
|
|
|
// Calculate new cursor (latest syncedAt timestamp)
|
|
let cursor = since
|
|
const allItems = [...appointments, ...medications, ...notes, ...doseLogs, ...symptoms]
|
|
for (const item of allItems) {
|
|
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
|
|
if (itemTime > cursor) {
|
|
cursor = itemTime
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
workspace,
|
|
appointments,
|
|
medications,
|
|
notes,
|
|
doseLogs,
|
|
symptoms,
|
|
cursor,
|
|
hasConflicts: false, // For now, always false - client handles conflicts
|
|
})
|
|
} catch (error) {
|
|
console.error('Sync get error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Sync failed' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
})
|
|
|
|
// POST /api/sync - Upload operations from client outbox
|
|
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
|
try {
|
|
const body = await req.json()
|
|
const result = syncOpsSchema.safeParse(body)
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid input', details: result.error.flatten() },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const { workspaceId, ops } = result.data
|
|
|
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
|
|
if (!access) {
|
|
return NextResponse.json(
|
|
{ error: 'Access denied' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
|
|
const results: { opId: string; success: boolean; entityId?: string; error?: string }[] = []
|
|
|
|
for (const op of ops) {
|
|
try {
|
|
switch (op.type) {
|
|
case 'CREATE': {
|
|
if (op.entityType === 'APPOINTMENT' && op.data) {
|
|
const appt = await prisma.appointment.create({
|
|
data: {
|
|
workspaceId,
|
|
title: op.data.title as string,
|
|
datetime: new Date(op.data.datetime as string),
|
|
location: (op.data.location as string) || null,
|
|
mapUrl: (op.data.mapUrl as string) || null,
|
|
notes: (op.data.notes as string) || null,
|
|
createdById: req.session.user.id,
|
|
updatedById: req.session.user.id,
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: appt.id })
|
|
} else if (op.entityType === 'NOTE' && op.data) {
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
workspaceId,
|
|
type: op.data.type as 'QUESTION' | 'GENERAL',
|
|
content: op.data.content as string,
|
|
createdById: req.session.user.id,
|
|
updatedById: req.session.user.id,
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: note.id })
|
|
} else {
|
|
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'UPDATE': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
if (op.entityType === 'APPOINTMENT' && op.data) {
|
|
const updateData: Record<string, unknown> = {
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
}
|
|
if (op.data.title) updateData.title = op.data.title as string
|
|
if (op.data.datetime) updateData.datetime = new Date(op.data.datetime as string)
|
|
if (op.data.location !== undefined) updateData.location = op.data.location as string | null
|
|
if (op.data.mapUrl !== undefined) updateData.mapUrl = op.data.mapUrl as string | null
|
|
if (op.data.notes !== undefined) updateData.notes = op.data.notes as string | null
|
|
|
|
await prisma.appointment.update({
|
|
where: { id: op.entityId },
|
|
data: updateData,
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: op.entityId })
|
|
} else if (op.entityType === 'NOTE' && op.data) {
|
|
const updateData: Record<string, unknown> = {
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
}
|
|
if (op.data.content) updateData.content = op.data.content as string
|
|
|
|
await prisma.note.update({
|
|
where: { id: op.entityId },
|
|
data: updateData,
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: op.entityId })
|
|
} else {
|
|
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'DELETE': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
if (op.entityType === 'APPOINTMENT') {
|
|
await prisma.appointment.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
deletedAt: new Date(),
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
} else if (op.entityType === 'NOTE') {
|
|
await prisma.note.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
deletedAt: new Date(),
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
} else {
|
|
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'TAKE_DOSE': {
|
|
if (!op.data?.medicationId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing medicationId' })
|
|
break
|
|
}
|
|
|
|
const doseLog = await prisma.doseLog.create({
|
|
data: {
|
|
workspaceId,
|
|
medicationId: op.data.medicationId as string,
|
|
takenAt: op.data.takenAt ? new Date(op.data.takenAt as string) : new Date(),
|
|
loggedById: req.session.user.id,
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: doseLog.id })
|
|
break
|
|
}
|
|
|
|
case 'UNDO_DOSE': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
await prisma.doseLog.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
undoneAt: new Date(),
|
|
undoneById: req.session.user.id,
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
break
|
|
}
|
|
|
|
case 'MARK_ASKED': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
await prisma.note.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
askedAt: new Date(),
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
break
|
|
}
|
|
|
|
case 'UNMARK_ASKED': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
await prisma.note.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
askedAt: null,
|
|
updatedById: req.session.user.id,
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
break
|
|
}
|
|
|
|
case 'LOG_SYMPTOM': {
|
|
if (!op.data) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing symptom data' })
|
|
break
|
|
}
|
|
|
|
const symptom = await prisma.symptom.create({
|
|
data: {
|
|
workspaceId,
|
|
type: op.data.type as 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM',
|
|
customName: (op.data.customName as string) || null,
|
|
severity: op.data.severity as number,
|
|
notes: (op.data.notes as string) || null,
|
|
recordedAt: op.data.recordedAt ? new Date(op.data.recordedAt as string) : new Date(),
|
|
createdById: req.session.user.id,
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true, entityId: symptom.id })
|
|
break
|
|
}
|
|
|
|
case 'DELETE_SYMPTOM': {
|
|
if (!op.entityId) {
|
|
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
|
break
|
|
}
|
|
|
|
await prisma.symptom.update({
|
|
where: { id: op.entityId },
|
|
data: {
|
|
deletedAt: new Date(),
|
|
version: { increment: 1 },
|
|
syncedAt: new Date(),
|
|
},
|
|
})
|
|
results.push({ opId: op.id, success: true })
|
|
break
|
|
}
|
|
|
|
default:
|
|
results.push({ opId: op.id, success: false, error: 'Unknown operation type' })
|
|
}
|
|
} catch (opError) {
|
|
console.error('Op error:', opError)
|
|
results.push({ opId: op.id, success: false, error: 'Operation failed' })
|
|
}
|
|
}
|
|
|
|
// Get new cursor
|
|
const cursor = Date.now()
|
|
|
|
return NextResponse.json({ results, cursor })
|
|
} catch (error) {
|
|
console.error('Sync post error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Sync failed' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
})
|