Initial commit: Next Step health management app

A calm, reliable app to help manage appointments, medications, and notes
for chemo patients and their families.

Features:
- Today dashboard with next appointment and medications due
- Medication tracking with multiple schedule types (fixed times, interval, weekdays, PRN)
- One-tap dose logging with 5-minute undo window
- Questions for doctor tracking
- Family sharing with workspace model and invite links
- Offline-first with IndexedDB and sync
- Docker Compose deployment with Tailscale Funnel support

Tech stack:
- Next.js 14 (App Router) + TypeScript + Tailwind CSS
- PostgreSQL + Prisma
- Argon2 password hashing + session cookies
- Dexie.js for IndexedDB

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-18 23:16:45 +00:00
commit a32c609830
76 changed files with 9406 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
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 { appointmentSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/appointments/[appointmentId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
if (!appointment) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
return NextResponse.json({ appointment })
} catch (error) {
console.error('Get appointment error:', error)
return NextResponse.json(
{ error: 'Failed to get appointment' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/appointments/[appointmentId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = 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.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
const body = await req.json()
const result = appointmentSchema.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,
version: { increment: 1 },
syncedAt: new Date(),
}
if (result.data.datetime) {
updateData.datetime = new Date(result.data.datetime)
}
const appointment = await prisma.appointment.update({
where: { id: appointmentId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'APPOINTMENT',
entityId: appointmentId,
details: result.data,
},
})
return NextResponse.json({ appointment })
} catch (error) {
console.error('Update appointment error:', error)
return NextResponse.json(
{ error: 'Failed to update appointment' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/appointments/[appointmentId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = 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.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
await prisma.appointment.update({
where: { id: appointmentId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'APPOINTMENT',
entityId: appointmentId,
details: { title: existing.title },
},
})
return NextResponse.json({ message: 'Appointment deleted' })
} catch (error) {
console.error('Delete appointment error:', error)
return NextResponse.json(
{ error: 'Failed to delete appointment' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,116 @@
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 { appointmentSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/appointments - List appointments
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const { searchParams } = new URL(req.url)
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const fromDate = searchParams.get('from')
const toDate = searchParams.get('to')
const where: Record<string, unknown> = {
workspaceId,
...(includeDeleted ? {} : { deletedAt: null }),
}
if (fromDate) {
where.datetime = { ...(where.datetime as object || {}), gte: new Date(fromDate) }
}
if (toDate) {
where.datetime = { ...(where.datetime as object || {}), lte: new Date(toDate) }
}
const appointments = await prisma.appointment.findMany({
where,
orderBy: { datetime: 'asc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ appointments })
} catch (error) {
console.error('List appointments error:', error)
return NextResponse.json(
{ error: 'Failed to list appointments' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/appointments - Create appointment
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 = appointmentSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const appointment = await prisma.appointment.create({
data: {
workspaceId,
title: result.data.title,
datetime: new Date(result.data.datetime),
location: result.data.location || null,
mapUrl: result.data.mapUrl || null,
notes: result.data.notes || null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'APPOINTMENT',
entityId: appointment.id,
details: { title: appointment.title },
},
})
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
console.error('Create appointment error:', error)
return NextResponse.json(
{ error: 'Failed to create appointment' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,221 @@
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 { doseLogSchema, undoDoseSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/doses - List dose logs
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 medicationId = searchParams.get('medicationId')
const fromDate = searchParams.get('from')
const toDate = searchParams.get('to')
const includeUndone = searchParams.get('includeUndone') === 'true'
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = {
workspaceId,
...(medicationId ? { medicationId } : {}),
...(includeUndone ? {} : { undoneAt: null }),
}
if (fromDate || toDate) {
where.takenAt = {}
if (fromDate) (where.takenAt as Record<string, unknown>).gte = new Date(fromDate)
if (toDate) (where.takenAt as Record<string, unknown>).lte = new Date(toDate)
}
const doseLogs = await prisma.doseLog.findMany({
where,
orderBy: { takenAt: 'desc' },
take: limit,
include: {
medication: {
select: { id: true, name: true },
},
loggedBy: { select: { id: true, name: true } },
undoneBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ doseLogs })
} catch (error) {
console.error('List dose logs error:', error)
return NextResponse.json(
{ error: 'Failed to list dose logs' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/doses - Log a dose (Take medication)
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 = doseLogSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
// Verify medication exists and belongs to workspace
const medication = await prisma.medication.findFirst({
where: {
id: result.data.medicationId,
workspaceId,
deletedAt: null,
},
})
if (!medication) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
const takenAt = result.data.takenAt ? new Date(result.data.takenAt) : new Date()
const doseLog = await prisma.doseLog.create({
data: {
medicationId: result.data.medicationId,
workspaceId,
takenAt,
loggedById: req.session.user.id,
},
include: {
medication: { select: { id: true, name: true } },
loggedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'TAKE_DOSE',
entityType: 'DOSE_LOG',
entityId: doseLog.id,
details: { medicationName: medication.name, takenAt: takenAt.toISOString() },
},
})
return NextResponse.json({ doseLog }, { status: 201 })
} catch (error) {
console.error('Log dose error:', error)
return NextResponse.json(
{ error: 'Failed to log dose' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/doses - Undo a dose
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 = undoDoseSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const doseLog = await prisma.doseLog.findFirst({
where: {
id: result.data.doseLogId,
workspaceId,
undoneAt: null,
},
include: {
medication: { select: { name: true } },
},
})
if (!doseLog) {
return NextResponse.json(
{ error: 'Dose log not found or already undone' },
{ status: 404 }
)
}
// Check if within undo window (5 minutes)
const minutesSinceDose = (Date.now() - doseLog.takenAt.getTime()) / 1000 / 60
if (minutesSinceDose > 5) {
return NextResponse.json(
{ error: 'Undo window has expired (5 minutes)' },
{ status: 400 }
)
}
const updated = await prisma.doseLog.update({
where: { id: doseLog.id },
data: {
undoneAt: new Date(),
undoneById: req.session.user.id,
},
include: {
medication: { select: { id: true, name: true } },
loggedBy: { select: { id: true, name: true } },
undoneBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UNDO_DOSE',
entityType: 'DOSE_LOG',
entityId: doseLog.id,
details: { medicationName: doseLog.medication.name },
},
})
return NextResponse.json({ doseLog: updated })
} catch (error) {
console.error('Undo dose error:', error)
return NextResponse.json(
{ error: 'Failed to undo dose' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { inviteSchema } from '@/lib/validation'
import { nanoid } from 'nanoid'
// Helper to check workspace access
async function checkWorkspaceAccess(
workspaceId: string,
userId: string,
requiredRoles: string[] = ['OWNER']
) {
const member = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
})
if (!member || !requiredRoles.includes(member.role)) {
return null
}
return member
}
// POST /api/workspaces/[id]/invite - Create invite token
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
// Only owners can create invites
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
if (!member) {
return NextResponse.json(
{ error: 'Only workspace owners can create invites' },
{ status: 403 }
)
}
const body = await req.json()
const result = inviteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { role, expiresInDays } = result.data
const token = nanoid(32)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + expiresInDays)
const invite = await prisma.inviteToken.create({
data: {
workspaceId: id,
token,
role,
expiresAt,
},
})
// Build invite URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/invite/${token}`
return NextResponse.json({
invite: {
token: invite.token,
role: invite.role,
expiresAt: invite.expiresAt,
url: inviteUrl,
},
})
} catch (error) {
console.error('Create invite error:', error)
return NextResponse.json(
{ error: 'Failed to create invite' },
{ status: 500 }
)
}
})
// GET /api/workspaces/[id]/invite - List active invites
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
if (!member) {
return NextResponse.json(
{ error: 'Only workspace owners can view invites' },
{ status: 403 }
)
}
const invites = await prisma.inviteToken.findMany({
where: {
workspaceId: id,
usedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
})
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
return NextResponse.json({
invites: invites.map((i) => ({
id: i.id,
token: i.token,
role: i.role,
expiresAt: i.expiresAt,
url: `${baseUrl}/invite/${i.token}`,
})),
})
} catch (error) {
console.error('List invites error:', error)
return NextResponse.json(
{ error: 'Failed to list invites' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,187 @@
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 { medicationSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications/[medicationId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const medication = await prisma.medication.findFirst({
where: { id: medicationId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
doseLogs: {
where: { undoneAt: null },
orderBy: { takenAt: 'desc' },
take: 10,
include: {
loggedBy: { select: { id: true, name: true } },
},
},
},
})
if (!medication) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
return NextResponse.json({ medication })
} catch (error) {
console.error('Get medication error:', error)
return NextResponse.json(
{ error: 'Failed to get medication' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/medications/[medicationId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = 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.medication.findFirst({
where: { id: medicationId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
const body = await req.json()
const result = medicationSchema.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,
version: { increment: 1 },
syncedAt: new Date(),
}
if (result.data.startDate !== undefined) {
updateData.startDate = result.data.startDate ? new Date(result.data.startDate) : null
}
if (result.data.endDate !== undefined) {
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
}
const medication = await prisma.medication.update({
where: { id: medicationId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'MEDICATION',
entityId: medicationId,
details: result.data,
},
})
return NextResponse.json({ medication })
} catch (error) {
console.error('Update medication error:', error)
return NextResponse.json(
{ error: 'Failed to update medication' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/medications/[medicationId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = 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.medication.findFirst({
where: { id: medicationId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
await prisma.medication.update({
where: { id: medicationId },
data: {
deletedAt: new Date(),
active: false,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'MEDICATION',
entityId: medicationId,
details: { name: existing.name },
},
})
return NextResponse.json({ message: 'Medication deleted' })
} catch (error) {
console.error('Delete medication error:', error)
return NextResponse.json(
{ error: 'Failed to delete medication' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,111 @@
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 { medicationSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications - List medications
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const { searchParams } = new URL(req.url)
const activeOnly = searchParams.get('activeOnly') !== 'false'
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const where: Record<string, unknown> = {
workspaceId,
...(includeDeleted ? {} : { deletedAt: null }),
...(activeOnly ? { active: true } : {}),
}
const medications = await prisma.medication.findMany({
where,
orderBy: { name: 'asc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ medications })
} catch (error) {
console.error('List medications error:', error)
return NextResponse.json(
{ error: 'Failed to list medications' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/medications - Create medication
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 = medicationSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const medication = await prisma.medication.create({
data: {
workspaceId,
name: result.data.name,
instructions: result.data.instructions || null,
scheduleType: result.data.scheduleType,
scheduleData: result.data.scheduleData,
startDate: result.data.startDate ? new Date(result.data.startDate) : null,
endDate: result.data.endDate ? new Date(result.data.endDate) : null,
active: result.data.active ?? true,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'MEDICATION',
entityId: medication.id,
details: { name: medication.name },
},
})
return NextResponse.json({ medication }, { status: 201 })
} catch (error) {
console.error('Create medication error:', error)
return NextResponse.json(
{ error: 'Failed to create medication' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,196 @@
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 { noteSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/notes/[noteId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const note = await prisma.note.findFirst({
where: { id: noteId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
if (!note) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
return NextResponse.json({ note })
} catch (error) {
console.error('Get note error:', error)
return NextResponse.json(
{ error: 'Failed to get note' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/notes/[noteId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = 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.note.findFirst({
where: { id: noteId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
const body = await req.json()
// Handle marking question as asked
if (body.markAsked === true && existing.type === 'QUESTION') {
const note = await prisma.note.update({
where: { id: noteId },
data: {
askedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
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: 'MARK_ASKED',
entityType: 'NOTE',
entityId: noteId,
},
})
return NextResponse.json({ note })
}
const result = noteSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const note = await prisma.note.update({
where: { id: noteId },
data: {
...result.data,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
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: 'NOTE',
entityId: noteId,
details: result.data,
},
})
return NextResponse.json({ note })
} catch (error) {
console.error('Update note error:', error)
return NextResponse.json(
{ error: 'Failed to update note' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/notes/[noteId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = 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.note.findFirst({
where: { id: noteId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
await prisma.note.update({
where: { id: noteId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'NOTE',
entityId: noteId,
},
})
return NextResponse.json({ message: 'Note deleted' })
} catch (error) {
console.error('Delete note error:', error)
return NextResponse.json(
{ error: 'Failed to delete note' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,106 @@
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 { noteSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/notes - List notes
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 type = searchParams.get('type') as 'QUESTION' | 'GENERAL' | null
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const where: Record<string, unknown> = {
workspaceId,
...(type ? { type } : {}),
...(includeDeleted ? {} : { deletedAt: null }),
}
const notes = await prisma.note.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ notes })
} catch (error) {
console.error('List notes error:', error)
return NextResponse.json(
{ error: 'Failed to list notes' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/notes - Create note
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
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 = noteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const note = await prisma.note.create({
data: {
workspaceId,
type: result.data.type,
content: result.data.content,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'NOTE',
entityId: note.id,
details: { type: note.type },
},
})
return NextResponse.json({ note }, { status: 201 })
} catch (error) {
console.error('Create note error:', error)
return NextResponse.json(
{ error: 'Failed to create note' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,145 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { updateWorkspaceSchema } from '@/lib/validation'
// Helper to check workspace access
async function checkWorkspaceAccess(
workspaceId: string,
userId: string,
requiredRoles: string[] = ['OWNER', 'EDITOR', 'VIEWER']
) {
const member = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
})
if (!member || !requiredRoles.includes(member.role)) {
return null
}
return member
}
// GET /api/workspaces/[id] - Get workspace details
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id)
if (!member) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const workspace = await prisma.workspace.findUnique({
where: { id },
include: {
members: {
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
})
if (!workspace) {
return NextResponse.json(
{ error: 'Workspace not found' },
{ status: 404 }
)
}
return NextResponse.json({
workspace: {
id: workspace.id,
name: workspace.name,
clinicPhone: workspace.clinicPhone,
emergencyPhone: workspace.emergencyPhone,
quietHoursStart: workspace.quietHoursStart,
quietHoursEnd: workspace.quietHoursEnd,
largeTextMode: workspace.largeTextMode,
role: member.role,
members: workspace.members.map((m) => ({
id: m.id,
role: m.role,
user: m.user,
})),
},
})
} catch (error) {
console.error('Get workspace error:', error)
return NextResponse.json(
{ error: 'Failed to get workspace' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id] - Update workspace settings
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER', 'EDITOR'])
if (!member) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const body = await req.json()
const result = updateWorkspaceSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const workspace = await prisma.workspace.update({
where: { id },
data: result.data,
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
quietHoursStart: true,
quietHoursEnd: true,
largeTextMode: true,
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId: id,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'WORKSPACE',
entityId: id,
details: result.data,
},
})
return NextResponse.json({ workspace })
} catch (error) {
console.error('Update workspace error:', error)
return NextResponse.json(
{ error: 'Failed to update workspace' },
{ status: 500 }
)
}
})