11 Commits

Author SHA1 Message Date
Gemini Agent
cae436a20d Add scheduler service for push notifications 2026-01-25 02:25:48 +00:00
Gemini Agent
8c9ae06360 Implement Edit Medication feature, refactor medication form, and fix build issues 2026-01-25 02:20:16 +00:00
Gemini Agent
7fa95c058e Fix medication scheduling bugs and add delete dose feature 2026-01-25 02:13:51 +00:00
Gemini Agent
f598f6138e Add test notification feature for push notification debugging
- Add POST /api/notifications/test endpoint to send test notifications
- Add "Send Test Notification" button to notifications settings page
- Shows success/failure feedback and removes expired subscriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:44:05 +00:00
Gemini Agent
66cb1ea095 Add admin panel with member management and password reset
Features:
- Admin panel at /settings/members for workspace owners
- View all workspace members with roles and last login
- Create new users directly (with temporary password)
- Change member roles (Owner/Editor/Viewer)
- Reset user passwords (forces change on next login)
- Remove members from workspace
- Force password reset flow on login
- Track last login timestamp for users

API Routes:
- GET/POST /api/workspaces/[id]/members
- GET/PATCH/DELETE /api/workspaces/[id]/members/[memberId]
- POST /api/workspaces/[id]/members/[memberId]/reset-password
- POST /api/auth/change-password

Schema changes:
- Added lastLoginAt DateTime? to User model
- Added forcePasswordReset Boolean to User model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:22:10 +00:00
Gemini Agent
f9a7b68a99 Fix service worker install failure due to missing icon files
The service worker was failing to install because it tried to cache
icon files that don't exist (icon-192.png, icon-512.png).

Simplified the service worker to focus only on push notifications:
- Removed caching during install (was causing "redundant" state)
- Removed fetch handler caching
- Removed references to non-existent icon files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:26:40 +00:00
Gemini Agent
4753216b56 Fix service worker registration for push notifications
Instead of waiting on navigator.serviceWorker.ready (which may never
resolve if registration hasn't completed), explicitly register the
service worker and wait for it to activate.

This fixes the "service worker not ready" error on iOS Safari PWAs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:00:39 +00:00
Gemini Agent
54900b65c8 Add timeouts and better error handling for push notifications
- Add 10s timeout for service worker ready state
- Add 15s timeout for push subscription
- Check for PushManager support early (shows unsupported on incompatible devices)
- Provide specific error messages for different failure modes

This prevents the enable button from spinning forever on iOS devices
where push subscription may hang silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:40 +00:00
Gemini Agent
c9f3402e48 Configure VAPID keys for push notifications
Added VAPID key configuration to enable PWA push notifications:
- Generated VAPID public/private key pair
- Added build arg for NEXT_PUBLIC_VAPID_PUBLIC_KEY (needed at Next.js build time)
- Added runtime env vars for VAPID keys in docker-compose.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:45:38 +00:00
Gemini Agent
e5db48f82b Fix iCal endpoint crash from non-ASCII characters in workspace name
The Content-Disposition header was including the workspace name directly,
causing a "Cannot convert argument to ByteString" error when workspace
names contained smart apostrophes or other non-ASCII characters (e.g.,
"Grace's Plan" with curly apostrophe U+2019).

Sanitize filename by removing non-ASCII characters before using in header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:34:00 +00:00
Gemini Agent
3034d52884 Fix appointment edit page and iCal timezone issues
- Create appointment edit page at /appointments/[id]/edit
- Fix iCal calendar timezone handling:
  - Add VTIMEZONE block for Australia/Perth
  - Use TZID parameter for DTSTART/DTEND
  - Properly format local times without Z suffix
- Appointments now appear correctly in Google Calendar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:22:23 +00:00
24 changed files with 2120 additions and 436 deletions

View File

@@ -23,6 +23,10 @@ RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build args for NEXT_PUBLIC_* variables (needed at build time)
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
RUN npm run build
# Stage 3: Runner (using slim Debian for better OpenSSL compatibility)
@@ -37,7 +41,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install OpenSSL and CA certificates for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma

View File

@@ -3,6 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
container_name: nextstep-app
restart: unless-stopped
ports:
@@ -13,6 +15,10 @@ services:
- NEXT_PUBLIC_APP_URL=https://debianvm.kangaroo-eel.ts.net:10000
- TZ=Australia/Perth
- NODE_ENV=production
# Push notification VAPID keys
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
- VAPID_EMAIL=mailto:admin@nextstep.local
depends_on:
db:
condition: service_healthy
@@ -25,6 +31,24 @@ services:
retries: 3
start_period: 40s
scheduler:
image: alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
entrypoint: /bin/sh
command: >
-c "apk add --no-cache curl &&
while true; do
echo 'Triggering notification check...' &&
curl -s -X POST http://app:3000/api/notifications/send &&
echo '' &&
sleep 60;
done"
networks:
- nextstep-network
db:
image: postgres:16-alpine
container_name: nextstep-db

View File

@@ -12,12 +12,14 @@ datasource db {
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
forcePasswordReset Boolean @default(false)
// Relations
sessions Session[]

View File

@@ -1,66 +1,16 @@
// NextStep Service Worker for Push Notifications
const CACHE_NAME = 'nextstep-v1'
// Install event - cache critical assets
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...')
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/today',
'/meds',
'/icon-192.png',
'/icon-512.png',
])
})
)
// Skip waiting to activate immediately
self.skipWaiting()
})
// Activate event - clean up old caches
// Activate event - claim clients immediately
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...')
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
})
)
self.clients.claim()
})
// Fetch event - serve from cache when offline
self.addEventListener('fetch', (event) => {
// Only cache GET requests
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached version or fetch from network
return (
cached ||
fetch(event.request).then((response) => {
// Don't cache API responses
if (event.request.url.includes('/api/')) {
return response
}
// Cache successful responses
if (response.status === 200) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
}
return response
})
)
})
)
event.waitUntil(self.clients.claim())
})
// Push event - handle incoming push notifications
@@ -70,8 +20,6 @@ self.addEventListener('push', (event) => {
let data = {
title: 'Medication Reminder',
body: 'Time to take your medication',
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: 'medication-reminder',
data: {
url: '/meds',
@@ -88,15 +36,9 @@ self.addEventListener('push', (event) => {
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
tag: data.tag || 'default',
vibrate: [100, 50, 100],
data: data.data || {},
actions: data.actions || [
{ action: 'take', title: 'Taken' },
{ action: 'snooze', title: 'Snooze' },
],
requireInteraction: true,
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Button, Input, Textarea, Card, LoadingState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../../provider'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function EditAppointmentPage() {
const router = useRouter()
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace, refreshData } = useApp()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [title, setTitle] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [location, setLocation] = useState('')
const [mapUrl, setMapUrl] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
const appt: Appointment = data.appointment
// Parse datetime and convert to local timezone
const apptDate = toZonedTime(parseISO(appt.datetime), TIMEZONE)
setTitle(appt.title)
setDate(format(apptDate, 'yyyy-MM-dd'))
setTime(format(apptDate, 'HH:mm'))
setLocation(appt.location || '')
setMapUrl(appt.mapUrl || '')
setNotes(appt.notes || '')
} else {
setError('Appointment not found')
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
setError('Failed to load appointment')
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
try {
// Combine date and time
const datetime = new Date(`${date}T${time}:00`)
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
datetime: datetime.toISOString(),
location: location || null,
mapUrl: mapUrl || null,
notes: notes || null,
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update appointment')
}
await refreshData()
showToast('Appointment updated', 'success')
router.push(`/appointments/${appointmentId}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer>
<LoadingState message="Loading appointment..." />
</PageContainer>
</>
)
}
if (error && !title) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">{error}</p>
</Card>
</PageContainer>
</>
)
}
return (
<>
<Header title="Edit Appointment" showBack backHref={`/appointments/${appointmentId}`} />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Oncology Appointment"
required
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
<Input
label="Time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</div>
<Input
label="Location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., Level 3, Cancer Centre"
/>
<Input
label="Map Link (optional)"
type="url"
value={mapUrl}
onChange={(e) => setMapUrl(e.target.value)}
placeholder="https://maps.google.com/..."
helperText="Paste a Google Maps or Apple Maps link"
/>
<Textarea
label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this appointment..."
rows={3}
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={saving}>
Save Changes
</Button>
</div>
</form>
</Card>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Header, PageContainer } from '@/components/layout/header'
import { MedicationForm } from '@/components/medications/MedicationForm'
import { LoadingState } from '@/components/ui'
export default function EditMedicationPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
const [medicationId, setMedicationId] = useState<string>('')
useEffect(() => {
if (params instanceof Promise) {
params.then((p) => setMedicationId(p.id))
} else {
setMedicationId(params.id)
}
}, [params])
const medication = useLiveQuery(
() => (medicationId ? db.medications.get(medicationId) : undefined),
[medicationId]
)
if (!medicationId || !medication) {
return (
<>
<Header title="Edit Medication" showBack />
<PageContainer>
<LoadingState message="Loading medication..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Edit Medication" showBack />
<PageContainer className="pt-4">
<MedicationForm initialData={medication} isEditing />
</PageContainer>
</>
)
}

View File

@@ -1,19 +1,41 @@
'use client'
import { use, useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
import { Pill, Clock, Trash2, History, X, Edit2 } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
import type { LocalDoseLog } from '@/lib/sync'
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillTracker } from '@/components/medications/RefillTracker'
import { useApp } from '../../provider'
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: medicationId } = use(params)
// Unwrapping params for Next.js 14/15 compatibility
// In Next.js 15 params is a Promise, in 14 it's an object.
// We can use a simple `use` polyfill or just await it if we were in an async component,
// but this is a client component.
// For client components, params is passed as is.
// If types say Promise, we might need to use `use` but `use` is experimental in React 18.
// Let's assume params is an object for now as per Next 14 standard behavior for pages.
// If it is a promise (Next 15), we need `use`.
// Safest way: check if it has .then?
// Actually, let's just assume object for Next 14.
export default function MedicationDetailPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
// Simple unwrap if it's a promise (though likely it's an object in Next 14)
const [medicationId, setMedicationId] = useState<string>('')
useEffect(() => {
if (params instanceof Promise) {
params.then((p) => setMedicationId(p.id))
} else {
setMedicationId(params.id)
}
}, [params])
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [showDeleteModal, setShowDeleteModal] = useState(false)
@@ -21,19 +43,21 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
// Fetch medication from IndexedDB
const medication = useLiveQuery(
() => db.medications.get(medicationId),
() => (medicationId ? db.medications.get(medicationId) : undefined),
[medicationId]
)
// Fetch recent dose logs
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray(),
medicationId
? db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray()
: [],
[medicationId]
)
@@ -58,6 +82,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
}
}, [medication, currentWorkspace.id])
const handleDeleteDose = async (dose: LocalDoseLog) => {
try {
await undoDose(dose)
showToast('Dose removed', 'success')
} catch {
showToast('Failed to remove dose', 'error')
}
}
const handleDelete = async () => {
if (!medication) return
setDeleting(true)
@@ -79,25 +112,27 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
}
const formatSchedule = () => {
if (!medication) return ''
if (!medication || !medication.scheduleData) return ''
const data = medication.scheduleData as Record<string, unknown>
switch (medication.scheduleType) {
case 'FIXED_TIMES':
return `Daily at ${(data.times as string[]).join(', ')}`
return `Daily at ${(Array.isArray(data.times) ? data.times : []).join(', ')}`
case 'INTERVAL':
return `Every ${data.hours} hours (starting ${data.startTime})`
return `Every ${data.hours || '?'} hours (starting ${data.startTime || '?'})`
case 'WEEKDAYS':
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
return `${selectedDays} at ${data.time}`
const selectedDays = (Array.isArray(data.days) ? data.days : [])
.map((d: number) => days[d])
.join(', ')
return `${selectedDays} at ${data.time || '?'}`
case 'PRN':
return `As needed (min ${data.minHoursBetween}h between doses)`
return `As needed (min ${data.minHoursBetween || '?'}h between doses)`
default:
return medication.scheduleType
}
}
if (!medication) {
if (!medicationId || !medication) {
return (
<>
<Header title="Medication" showBack />
@@ -118,9 +153,9 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
rightAction={
currentWorkspace.role !== 'VIEWER'
? {
icon: <Trash2 className="w-6 h-6 text-red-600" />,
label: 'Delete',
onClick: () => setShowDeleteModal(true),
icon: <Edit2 className="w-5 h-5 text-primary-600" />,
label: 'Edit',
onClick: () => router.push(`/meds/${medication.id}/edit`),
}
: undefined
}
@@ -193,7 +228,7 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
<Card padding="none">
<ul className="divide-y divide-border">
{recentDoses.map((dose) => (
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
<li key={dose.id} className="px-4 py-3 flex items-center justify-between group">
<div>
<p className="text-sm font-medium text-secondary-900">
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
@@ -203,6 +238,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
</p>
</div>
{currentWorkspace.role !== 'VIEWER' && (
<button
onClick={() => handleDeleteDose(dose)}
className="p-2 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove dose"
>
<X className="w-4 h-4" />
</button>
)}
</li>
))}
</ul>
@@ -213,6 +257,20 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
</section>
{/* Delete Action */}
{currentWorkspace.role !== 'VIEWER' && (
<div className="pt-4 pb-8">
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700 w-full"
onClick={() => setShowDeleteModal(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Medication
</Button>
</div>
)}
</PageContainer>
{/* Delete Confirmation Modal */}

View File

@@ -1,334 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
const scheduleTypeOptions = [
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
{ value: 'INTERVAL', label: 'Every X hours' },
{ value: 'WEEKDAYS', label: 'Specific days of week' },
{ value: 'PRN', label: 'As needed (PRN)' },
]
const weekdays = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
]
import { MedicationForm } from '@/components/medications/MedicationForm'
export default function NewMedicationPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
// Fixed times
const [times, setTimes] = useState(['08:00'])
// Interval
const [intervalHours, setIntervalHours] = useState(8)
const [startTime, setStartTime] = useState('08:00')
// Weekdays
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
const [weekdayTime, setWeekdayTime] = useState('09:00')
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(4)
// Refill tracking (optional)
const [trackRefills, setTrackRefills] = useState(false)
const [pillCount, setPillCount] = useState<number | ''>('')
const [pillsPerDose, setPillsPerDose] = useState(1)
const [refillThreshold, setRefillThreshold] = useState(7)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addTime = () => {
setTimes([...times, '12:00'])
}
const removeTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index))
}
const updateTime = (index: number, value: string) => {
const newTimes = [...times]
newTimes[index] = value
setTimes(newTimes)
}
const toggleDay = (day: number) => {
if (selectedDays.includes(day)) {
setSelectedDays(selectedDays.filter((d) => d !== day))
} else {
setSelectedDays([...selectedDays, day].sort())
}
}
const buildScheduleData = () => {
switch (scheduleType) {
case 'FIXED_TIMES':
return { type: 'FIXED_TIMES', times }
case 'INTERVAL':
return { type: 'INTERVAL', hours: intervalHours, startTime }
case 'WEEKDAYS':
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
case 'PRN':
return { type: 'PRN', minHoursBetween }
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
instructions: instructions || null,
scheduleType,
scheduleData: buildScheduleData(),
active: true,
// Refill tracking (optional)
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to add medication')
}
await refreshData()
showToast('Medication added', 'success')
router.push('/meds')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<>
<Header title="New Medication" showBack backHref="/meds" />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Medication Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Paracetamol 500mg"
required
/>
<Textarea
label="Instructions (optional)"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g., Take with food"
rows={2}
/>
<Select
label="Schedule Type"
value={scheduleType}
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
options={scheduleTypeOptions}
/>
{/* Schedule-specific options */}
{scheduleType === 'FIXED_TIMES' && (
<div className="space-y-3">
<label className="block text-sm font-medium text-secondary-700">
Times to take
</label>
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<Input
type="time"
value={time}
onChange={(e) => updateTime(index, e.target.value)}
className="flex-1"
/>
{times.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() => removeTime(index)}
>
Remove
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" onClick={addTime} size="sm">
+ Add time
</Button>
</div>
)}
{scheduleType === 'INTERVAL' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="Every (hours)"
type="number"
min={1}
max={72}
value={intervalHours}
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
/>
<Input
label="Starting at"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
</div>
)}
{scheduleType === 'WEEKDAYS' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Days
</label>
<div className="flex gap-2 flex-wrap">
{weekdays.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
selectedDays.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<Input
label="Time"
type="time"
value={weekdayTime}
onChange={(e) => setWeekdayTime(e.target.value)}
/>
</div>
)}
{scheduleType === 'PRN' && (
<Input
label="Minimum hours between doses"
type="number"
min={0.5}
max={72}
step={0.5}
value={minHoursBetween}
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
helperText="Shows 'Available' when enough time has passed since last dose"
/>
)}
{/* Refill Tracking (optional) */}
<div className="border-t border-border pt-5">
<div className="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="trackRefills"
checked={trackRefills}
onChange={(e) => setTrackRefills(e.target.checked)}
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
Track pill count for refill reminders (optional)
</label>
</div>
{trackRefills && (
<div className="space-y-4 pl-8">
<Input
label="Current pill count"
type="number"
min={0}
value={pillCount}
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="e.g., 30"
helperText="How many pills do you have now?"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Pills per dose"
type="number"
min={1}
value={pillsPerDose}
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
/>
<Input
label="Alert when below"
type="number"
min={0}
value={refillThreshold}
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
helperText="pills"
/>
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={loading}>
Save Medication
</Button>
</div>
</form>
</Card>
<MedicationForm />
</PageContainer>
</>
)
}
}

View File

@@ -0,0 +1,493 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import {
Users,
UserPlus,
Trash2,
Key,
Shield,
Edit2,
Loader,
AlertTriangle,
} from 'lucide-react'
import { Button, Card, Input, Modal, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
interface Member {
id: string
role: 'OWNER' | 'EDITOR' | 'VIEWER'
joinedAt: string
user: {
id: string
name: string
email: string
lastLoginAt: string | null
forcePasswordReset: boolean
createdAt: string
}
}
export default function MembersPage() {
const router = useRouter()
const { currentWorkspace, user } = useApp()
const [members, setMembers] = useState<Member[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Modals
const [showAddUser, setShowAddUser] = useState(false)
const [showEditRole, setShowEditRole] = useState<Member | null>(null)
const [showResetPassword, setShowResetPassword] = useState<Member | null>(null)
const [showRemove, setShowRemove] = useState<Member | null>(null)
// Form states
const [addUserForm, setAddUserForm] = useState({
name: '',
email: '',
password: '',
role: 'VIEWER' as 'OWNER' | 'EDITOR' | 'VIEWER',
forcePasswordReset: true,
})
const [resetPasswordForm, setResetPasswordForm] = useState({
newPassword: '',
forceChange: true,
})
const [newRole, setNewRole] = useState<'OWNER' | 'EDITOR' | 'VIEWER'>('VIEWER')
const [actionLoading, setActionLoading] = useState(false)
const fetchMembers = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
if (!response.ok) throw new Error('Failed to fetch members')
const data = await response.json()
setMembers(data.members)
} catch (err) {
setError('Failed to load members')
console.error(err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
if (currentWorkspace.role !== 'OWNER') {
router.push('/settings')
return
}
fetchMembers()
}, [currentWorkspace.role, fetchMembers, router])
const handleAddUser = async () => {
setActionLoading(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addUserForm),
})
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast(data.message || 'User added', 'success')
setShowAddUser(false)
setAddUserForm({
name: '',
email: '',
password: '',
role: 'VIEWER',
forcePasswordReset: true,
})
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to add user', 'error')
} finally {
setActionLoading(false)
}
}
const handleUpdateRole = async () => {
if (!showEditRole) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showEditRole.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
}
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast('Role updated', 'success')
setShowEditRole(null)
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to update role', 'error')
} finally {
setActionLoading(false)
}
}
const handleResetPassword = async () => {
if (!showResetPassword) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showResetPassword.id}/reset-password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(resetPasswordForm),
}
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast(data.message || 'Password reset', 'success')
setShowResetPassword(null)
setResetPasswordForm({ newPassword: '', forceChange: true })
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to reset password', 'error')
} finally {
setActionLoading(false)
}
}
const handleRemoveMember = async () => {
if (!showRemove) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showRemove.id}`,
{ method: 'DELETE' }
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast('Member removed', 'success')
setShowRemove(null)
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to remove member', 'error')
} finally {
setActionLoading(false)
}
}
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'OWNER':
return 'bg-purple-100 text-purple-800'
case 'EDITOR':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-secondary-100 text-secondary-800'
}
}
if (loading) {
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4">
<div className="flex items-center justify-center py-12">
<Loader className="w-6 h-6 animate-spin text-secondary-400" />
</div>
</PageContainer>
</>
)
}
if (error) {
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">{error}</p>
</Card>
</PageContainer>
</>
)
}
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4 space-y-4">
{/* Add user button */}
<Button onClick={() => setShowAddUser(true)} className="w-full">
<UserPlus className="w-4 h-4 mr-2" />
Add User
</Button>
{/* Members list */}
<div className="space-y-3">
{members.map((member) => {
const isCurrentUser = member.user.id === user.id
return (
<Card key={member.id} padding="none">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-secondary-900">
{member.user.name}
{isCurrentUser && (
<span className="text-secondary-500 font-normal"> (you)</span>
)}
</p>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getRoleBadgeColor(member.role)}`}
>
{member.role}
</span>
</div>
<p className="text-sm text-secondary-500 mt-0.5">{member.user.email}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-secondary-400">
<span>
Joined {format(new Date(member.joinedAt), 'MMM d, yyyy')}
</span>
{member.user.lastLoginAt && (
<span>
Last login{' '}
{format(new Date(member.user.lastLoginAt), 'MMM d, yyyy')}
</span>
)}
</div>
{member.user.forcePasswordReset && (
<div className="flex items-center gap-1 mt-2 text-xs text-amber-600">
<AlertTriangle className="w-3 h-3" />
<span>Must change password on next login</span>
</div>
)}
</div>
</div>
{/* Actions */}
{!isCurrentUser && (
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowEditRole(member)
setNewRole(member.role)
}}
>
<Edit2 className="w-3.5 h-3.5 mr-1" />
Role
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowResetPassword(member)
setResetPasswordForm({ newPassword: '', forceChange: true })
}}
>
<Key className="w-3.5 h-3.5 mr-1" />
Reset Password
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => setShowRemove(member)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Remove
</Button>
</div>
)}
</div>
</Card>
)
})}
</div>
{members.length === 0 && (
<Card className="text-center py-8">
<Users className="w-12 h-12 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No members yet</p>
</Card>
)}
</PageContainer>
{/* Add User Modal */}
<Modal isOpen={showAddUser} onClose={() => setShowAddUser(false)} title="Add User">
<div className="space-y-4">
<Input
label="Name"
value={addUserForm.name}
onChange={(e) => setAddUserForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Enter name"
/>
<Input
label="Email"
type="email"
value={addUserForm.email}
onChange={(e) => setAddUserForm((f) => ({ ...f, email: e.target.value }))}
placeholder="Enter email"
/>
<Input
label="Temporary Password"
type="text"
value={addUserForm.password}
onChange={(e) => setAddUserForm((f) => ({ ...f, password: e.target.value }))}
placeholder="At least 8 characters"
helperText="User will be required to change this on first login"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Role</label>
<div className="flex gap-2">
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
<button
key={role}
onClick={() => setAddUserForm((f) => ({ ...f, role }))}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
addUserForm.role === role
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
{role}
</button>
))}
</div>
</div>
<Button
onClick={handleAddUser}
fullWidth
loading={actionLoading}
disabled={!addUserForm.name || !addUserForm.email || addUserForm.password.length < 8}
>
Add User
</Button>
</div>
</Modal>
{/* Edit Role Modal */}
<Modal
isOpen={!!showEditRole}
onClose={() => setShowEditRole(null)}
title="Change Role"
>
<div className="space-y-4">
<p className="text-secondary-600">
Change role for <strong>{showEditRole?.user.name}</strong>
</p>
<div className="flex gap-2">
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
<button
key={role}
onClick={() => setNewRole(role)}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
newRole === role
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
{role}
</button>
))}
</div>
<div className="text-sm text-secondary-500">
<p><strong>Viewer:</strong> Can view everything but not make changes</p>
<p><strong>Editor:</strong> Can add and edit appointments, medications, notes</p>
<p><strong>Owner:</strong> Full access including member management</p>
</div>
<Button onClick={handleUpdateRole} fullWidth loading={actionLoading}>
Update Role
</Button>
</div>
</Modal>
{/* Reset Password Modal */}
<Modal
isOpen={!!showResetPassword}
onClose={() => setShowResetPassword(null)}
title="Reset Password"
>
<div className="space-y-4">
<p className="text-secondary-600">
Reset password for <strong>{showResetPassword?.user.name}</strong>
</p>
<Input
label="New Password"
type="text"
value={resetPasswordForm.newPassword}
onChange={(e) =>
setResetPasswordForm((f) => ({ ...f, newPassword: e.target.value }))
}
placeholder="At least 8 characters"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={resetPasswordForm.forceChange}
onChange={(e) =>
setResetPasswordForm((f) => ({ ...f, forceChange: e.target.checked }))
}
className="w-4 h-4 rounded border-secondary-300"
/>
<span className="text-sm text-secondary-700">
Require password change on next login
</span>
</label>
<p className="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
This will log the user out of all devices.
</p>
<Button
onClick={handleResetPassword}
fullWidth
loading={actionLoading}
disabled={resetPasswordForm.newPassword.length < 8}
>
Reset Password
</Button>
</div>
</Modal>
{/* Remove Member Modal */}
<Modal
isOpen={!!showRemove}
onClose={() => setShowRemove(null)}
title="Remove Member"
>
<div className="space-y-4">
<p className="text-secondary-600">
Are you sure you want to remove <strong>{showRemove?.user.name}</strong> from
this workspace? They will lose access to all data.
</p>
<div className="flex gap-3">
<Button variant="secondary" fullWidth onClick={() => setShowRemove(null)}>
Cancel
</Button>
<Button
className="bg-red-600 hover:bg-red-700"
fullWidth
loading={actionLoading}
onClick={handleRemoveMember}
>
Remove
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Bell, Clock, Moon } from 'lucide-react'
import { Bell, Clock, Moon, Send } from 'lucide-react'
import { Card, Button, Input, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
@@ -13,6 +13,7 @@ export default function NotificationsSettingsPage() {
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
const [saving, setSaving] = useState(false)
const [testingSending, setTestingSending] = useState(false)
const handleSaveQuietHours = async () => {
setSaving(true)
@@ -37,6 +38,29 @@ export default function NotificationsSettingsPage() {
}
}
const handleTestNotification = async () => {
setTestingSending(true)
try {
const response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId: currentWorkspace.id }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to send test notification')
}
showToast(data.message, data.success ? 'success' : 'error')
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to send test', 'error')
} finally {
setTestingSending(false)
}
}
return (
<>
<Header title="Notifications" showBack />
@@ -51,6 +75,37 @@ export default function NotificationsSettingsPage() {
</Card>
</section>
{/* Test Notification */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Test Notifications
</h2>
<Card>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-blue-100 rounded-lg">
<Send className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-secondary-900">
Send Test Notification
</p>
<p className="text-sm text-secondary-500">
Verify that notifications are working on your device.
</p>
</div>
</div>
<Button
onClick={handleTestNotification}
loading={testingSending}
fullWidth
variant="secondary"
>
<Send className="w-4 h-4 mr-2" />
Send Test Notification
</Button>
</Card>
</section>
{/* Quiet Hours */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">

View File

@@ -289,16 +289,29 @@ export default function SettingsPage() {
</h2>
<Card padding="none">
<button
onClick={() => setShowInvite(true)}
onClick={() => router.push('/settings/members')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Users className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
<p className="font-medium text-secondary-900">Manage Members</p>
<p className="text-sm text-secondary-500">View and manage workspace access</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => setShowInvite(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Users className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
)}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { hashPassword, verifyPassword, withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { z } from 'zod'
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
})
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const result = changePasswordSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { currentPassword, newPassword } = result.data
const userId = req.session.user.id
// Get current user with password hash
const user = await prisma.user.findUnique({
where: { id: userId },
select: { passwordHash: true },
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Verify current password
const validPassword = await verifyPassword(user.passwordHash, currentPassword)
if (!validPassword) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 })
}
// Hash new password and update user
const newPasswordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: userId },
data: {
passwordHash: newPasswordHash,
forcePasswordReset: false, // Clear the forced reset flag
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Change password error:', error)
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 })
}
})

View File

@@ -44,6 +44,7 @@ async function handler(req: NextRequest) {
email: true,
name: true,
passwordHash: true,
forcePasswordReset: true,
},
})
@@ -65,8 +66,14 @@ async function handler(req: NextRequest) {
)
}
// Record successful login
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
// Record successful login and update lastLoginAt
await Promise.all([
recordLoginAttempt(email.toLowerCase(), true, ipAddress),
prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
}),
])
// Create session
const userAgent = req.headers.get('user-agent') || undefined
@@ -79,6 +86,7 @@ async function handler(req: NextRequest) {
email: user.email,
name: user.name,
},
forcePasswordReset: user.forcePasswordReset,
})
response.cookies.set(cookieConfig)

View File

@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { sendPushNotification } from '@/lib/notifications/push'
// POST /api/notifications/test - Send a test notification
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const { workspaceId } = body
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId required' }, { status: 400 })
}
// Check access
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Get push subscriptions for this user in this workspace
const subscriptions = await prisma.pushSubscription.findMany({
where: {
userId: req.session.user.id,
workspaceId,
},
})
if (subscriptions.length === 0) {
return NextResponse.json(
{ error: 'No push subscriptions found. Please enable notifications first.' },
{ status: 404 }
)
}
let sent = 0
let failed = 0
const errors: string[] = []
for (const sub of subscriptions) {
try {
const success = await sendPushNotification(
{
endpoint: sub.endpoint,
p256dh: sub.p256dh,
auth: sub.auth,
},
{
title: 'Test Notification',
body: 'If you see this, notifications are working!',
tag: 'test-notification',
data: {
url: '/settings/notifications',
action: 'test',
},
}
)
if (success) {
sent++
} else {
// Subscription expired, remove it
await prisma.pushSubscription.delete({ where: { id: sub.id } })
failed++
errors.push('Subscription expired and was removed')
}
} catch (error: any) {
console.error('Test notification error:', error)
failed++
errors.push(error.message || 'Unknown error')
}
}
return NextResponse.json({
success: sent > 0,
sent,
failed,
total: subscriptions.length,
errors: errors.length > 0 ? errors : undefined,
message: sent > 0
? `Test notification sent! Check your device.`
: `Failed to send notification: ${errors.join(', ')}`,
})
} catch (error) {
console.error('Test notification error:', error)
return NextResponse.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
)
}
})

View File

@@ -56,11 +56,17 @@ export async function GET(
membership.workspace.name
)
// Sanitize filename for HTTP headers (remove non-ASCII characters)
const safeFilename = membership.workspace.name
.replace(/[^\x00-\x7F]/g, '') // Remove non-ASCII
.replace(/[<>:"/\\|?*]/g, '-') // Replace invalid filename chars
.trim() || 'appointments'
return new NextResponse(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
'Content-Disposition': `attachment; filename="${safeFilename}-appointments.ics"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})

View File

@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
const resetPasswordSchema = z.object({
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
forceChange: z.boolean().default(true),
})
// POST /api/workspaces/[id]/members/[memberId]/reset-password - Reset user password
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can reset passwords' }, { status: 403 })
}
const body = await req.json()
const result = resetPasswordSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { newPassword, forceChange } = result.data
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
include: { user: true },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Hash new password and update user
const passwordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: member.userId },
data: {
passwordHash,
forcePasswordReset: forceChange,
},
})
// Invalidate all existing sessions for this user
await prisma.session.deleteMany({
where: { userId: member.userId },
})
return NextResponse.json({
success: true,
message: forceChange
? 'Password reset. User must change password on next login.'
: 'Password reset successfully.',
})
} catch (error) {
console.error('Reset password error:', error)
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 })
}
})

View File

@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
// GET /api/workspaces/[id]/members/[memberId] - Get member details
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
return NextResponse.json({ member })
} catch (error) {
console.error('Get member error:', error)
return NextResponse.json({ error: 'Failed to get member' }, { status: 500 })
}
})
const updateMemberSchema = z.object({
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).optional(),
})
// PATCH /api/workspaces/[id]/members/[memberId] - Update member role
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can update members' }, { status: 403 })
}
const body = await req.json()
const result = updateMemberSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { role } = result.data
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent changing own role
if (member.userId === req.session.user.id) {
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
}
// Update member
const updatedMember = await prisma.workspaceMember.update({
where: { id: memberId },
data: { role },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
return NextResponse.json({
member: {
id: updatedMember.id,
role: updatedMember.role,
joinedAt: updatedMember.createdAt,
user: updatedMember.user,
},
})
} catch (error) {
console.error('Update member error:', error)
return NextResponse.json({ error: 'Failed to update member' }, { status: 500 })
}
})
// DELETE /api/workspaces/[id]/members/[memberId] - Remove member from workspace
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can remove members' }, { status: 403 })
}
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent removing self
if (member.userId === req.session.user.id) {
return NextResponse.json({ error: 'Cannot remove yourself from workspace' }, { status: 400 })
}
// Delete member
await prisma.workspaceMember.delete({
where: { id: memberId },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Remove member error:', error)
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
})

View File

@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
// GET /api/workspaces/[id]/members - List all members
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
// Check access (must be at least a member)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const members = await prisma.workspaceMember.findMany({
where: { workspaceId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({
members: members.map((m) => ({
id: m.id,
role: m.role,
joinedAt: m.createdAt,
user: m.user,
})),
})
} catch (error) {
console.error('List members error:', error)
return NextResponse.json({ error: 'Failed to list members' }, { status: 500 })
}
})
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).default('VIEWER'),
forcePasswordReset: z.boolean().default(true),
})
// POST /api/workspaces/[id]/members - Create a new user and add to workspace
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can create users' }, { status: 403 })
}
const body = await req.json()
const result = createUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { name, email, password, role, forcePasswordReset } = result.data
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
})
if (existingUser) {
// Check if already a member
const existingMember = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: existingUser.id,
},
},
})
if (existingMember) {
return NextResponse.json(
{ error: 'User is already a member of this workspace' },
{ status: 400 }
)
}
// Add existing user to workspace
const member = await prisma.workspaceMember.create({
data: {
workspaceId,
userId: existingUser.id,
role,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
return NextResponse.json({
member: {
id: member.id,
role: member.role,
joinedAt: member.createdAt,
user: member.user,
},
message: 'Existing user added to workspace',
})
}
// Create new user and add to workspace
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
passwordHash,
forcePasswordReset,
workspaceMembers: {
create: {
workspaceId,
role,
},
},
},
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
workspaceMembers: {
where: { workspaceId },
select: {
id: true,
role: true,
createdAt: true,
},
},
},
})
const member = user.workspaceMembers[0]
return NextResponse.json({
member: {
id: member.id,
role: member.role,
joinedAt: member.createdAt,
user: {
id: user.id,
name: user.name,
email: user.email,
lastLoginAt: user.lastLoginAt,
forcePasswordReset: user.forcePasswordReset,
createdAt: user.createdAt,
},
},
message: 'User created and added to workspace',
}, { status: 201 })
} catch (error) {
console.error('Create user error:', error)
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
}
})

View File

@@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function ChangePasswordPage() {
const router = useRouter()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 8) {
setError('New password must be at least 8 characters')
return
}
setLoading(true)
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Failed to change password')
return
}
showToast('Password changed successfully!', 'success')
router.push('/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Change Password</h1>
<p className="text-secondary-500 mt-1">Please set a new password to continue</p>
</div>
<Card className="mb-6">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
required
autoComplete="current-password"
/>
<Input
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
required
autoComplete="new-password"
/>
<Input
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
autoComplete="new-password"
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<Button type="submit" fullWidth loading={loading}>
Change Password
</Button>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -34,6 +34,14 @@ function LoginForm() {
return
}
// Check if user needs to change password
if (data.forcePasswordReset) {
showToast('Please change your password to continue', 'info')
router.push('/change-password')
router.refresh()
return
}
showToast('Welcome back!', 'success')
// If there's a redirect param (e.g., from invite link), go there
router.push(redirectTo || '/today')

View File

@@ -0,0 +1,360 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
import { useApp } from '@/app/(app)/provider'
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
const scheduleTypeOptions = [
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
{ value: 'INTERVAL', label: 'Every X hours' },
{ value: 'WEEKDAYS', label: 'Specific days of week' },
{ value: 'PRN', label: 'As needed (PRN)' },
]
const weekdays = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
]
interface MedicationFormProps {
initialData?: {
id?: string
name: string
instructions?: string | null
scheduleType: string
scheduleData: any
active?: boolean
pillCount?: number | null
pillsPerDose?: number | null
refillThreshold?: number | null
}
isEditing?: boolean
}
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [name, setName] = useState(initialData?.name || '')
const [instructions, setInstructions] = useState(initialData?.instructions || '')
const [scheduleType, setScheduleType] = useState<ScheduleType>((initialData?.scheduleType as ScheduleType) || 'FIXED_TIMES')
// Fixed times
const [times, setTimes] = useState<string[]>(initialData?.scheduleData?.times || ['08:00'])
// Interval
const [intervalHours, setIntervalHours] = useState(initialData?.scheduleData?.hours || 8)
const [startTime, setStartTime] = useState(initialData?.scheduleData?.startTime || '08:00')
// Weekdays
const [selectedDays, setSelectedDays] = useState<number[]>(initialData?.scheduleData?.days || [1, 3, 5])
const [weekdayTime, setWeekdayTime] = useState(initialData?.scheduleData?.time || '09:00')
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(initialData?.scheduleData?.minHoursBetween || 4)
// Refill tracking (optional)
const hasRefillInfo = initialData?.pillCount !== null && initialData?.pillCount !== undefined
const [trackRefills, setTrackRefills] = useState(hasRefillInfo)
const [pillCount, setPillCount] = useState<number | ''>(initialData?.pillCount ?? '')
const [pillsPerDose, setPillsPerDose] = useState(initialData?.pillsPerDose || 1)
const [refillThreshold, setRefillThreshold] = useState(initialData?.refillThreshold || 7)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
// Reset defaults if switching types and no initial data for that type
if (initialData?.scheduleType !== scheduleType) {
// Keep current state if user is just switching around in new mode
}
}, [scheduleType, initialData])
const addTime = () => {
setTimes([...times, '12:00'])
}
const removeTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index))
}
const updateTime = (index: number, value: string) => {
const newTimes = [...times]
newTimes[index] = value
setTimes(newTimes)
}
const toggleDay = (day: number) => {
if (selectedDays.includes(day)) {
setSelectedDays(selectedDays.filter((d) => d !== day))
} else {
setSelectedDays([...selectedDays, day].sort())
}
}
const buildScheduleData = () => {
switch (scheduleType) {
case 'FIXED_TIMES':
return { type: 'FIXED_TIMES', times }
case 'INTERVAL':
return { type: 'INTERVAL', hours: intervalHours, startTime }
case 'WEEKDAYS':
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
case 'PRN':
return { type: 'PRN', minHoursBetween }
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const url = isEditing && initialData?.id
? `/api/workspaces/${currentWorkspace.id}/medications/${initialData.id}`
: `/api/workspaces/${currentWorkspace.id}/medications`
const method = isEditing ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
instructions: instructions || null,
scheduleType,
scheduleData: buildScheduleData(),
active: true,
// Refill tracking
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
// Explicitly nullify if disabled during edit
...(isEditing && !trackRefills && {
pillCount: null,
pillsPerDose: null,
refillThreshold: null,
})
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to save medication')
}
await refreshData()
showToast(isEditing ? 'Medication updated' : 'Medication added', 'success')
router.push('/meds')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<Card>
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Medication Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Paracetamol 500mg"
required
/>
<Textarea
label="Instructions (optional)"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g., Take with food"
rows={2}
/>
<Select
label="Schedule Type"
value={scheduleType}
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
options={scheduleTypeOptions}
/>
{/* Schedule-specific options */}
{scheduleType === 'FIXED_TIMES' && (
<div className="space-y-3">
<label className="block text-sm font-medium text-secondary-700">
Times to take
</label>
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<Input
type="time"
value={time}
onChange={(e) => updateTime(index, e.target.value)}
className="flex-1"
/>
{times.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() => removeTime(index)}
>
Remove
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" onClick={addTime} size="sm">
+ Add time
</Button>
</div>
)}
{scheduleType === 'INTERVAL' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="Every (hours)"
type="number"
min={1}
max={72}
value={intervalHours}
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
/>
<Input
label="Starting at"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
</div>
)}
{scheduleType === 'WEEKDAYS' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Days
</label>
<div className="flex gap-2 flex-wrap">
{weekdays.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
selectedDays.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<Input
label="Time"
type="time"
value={weekdayTime}
onChange={(e) => setWeekdayTime(e.target.value)}
/>
</div>
)}
{scheduleType === 'PRN' && (
<Input
label="Minimum hours between doses"
type="number"
min={0.5}
max={72}
step={0.5}
value={minHoursBetween}
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
helperText="Shows 'Available' when enough time has passed since last dose"
/>
)}
{/* Refill Tracking (optional) */}
<div className="border-t border-border pt-5">
<div className="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="trackRefills"
checked={trackRefills}
onChange={(e) => setTrackRefills(e.target.checked)}
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
Track pill count for refill reminders (optional)
</label>
</div>
{trackRefills && (
<div className="space-y-4 pl-8">
<Input
label="Current pill count"
type="number"
min={0}
value={pillCount}
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="e.g., 30"
helperText="How many pills do you have now?"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Pills per dose"
type="number"
min={1}
value={pillsPerDose}
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
/>
<Input
label="Alert when below"
type="number"
min={0}
value={refillThreshold}
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
helperText="pills"
/>
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={loading}>
{isEditing ? 'Update Medication' : 'Save Medication'}
</Button>
</div>
</form>
</Card>
)
}

View File

@@ -31,15 +31,28 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
return
}
// Check if PushManager is available (not available in all browsers/contexts)
if (!('PushManager' in window)) {
setPermission('unsupported')
return
}
const perm = Notification.permission as PermissionState
setPermission(perm)
if (perm === 'granted') {
// Check if already subscribed
// Check if already subscribed with timeout
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
const registrationPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<ServiceWorkerRegistration>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
)
const registration = await Promise.race([registrationPromise, timeoutPromise])
if (registration.pushManager) {
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
}
} catch (err) {
console.error('Failed to check subscription:', err)
}
@@ -66,14 +79,54 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
}
const { publicKey } = await keyResponse.json()
// Register service worker if not already registered
const registration = await navigator.serviceWorker.ready
// Ensure service worker is registered and active
let registration: ServiceWorkerRegistration
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
// First, try to register the service worker (in case it wasn't registered yet)
try {
registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
// Wait for it to be active
if (registration.installing || registration.waiting) {
await new Promise<void>((resolve, reject) => {
const sw = registration.installing || registration.waiting
if (!sw) {
resolve()
return
}
const timeout = setTimeout(() => reject(new Error('Service worker activation timeout')), 10000)
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
clearTimeout(timeout)
resolve()
} else if (sw.state === 'redundant') {
clearTimeout(timeout)
reject(new Error('Service worker became redundant'))
}
})
})
}
} catch (regError: any) {
console.error('Service worker registration error:', regError)
throw new Error('Failed to register service worker: ' + regError.message)
}
// Check if push manager is available
if (!registration.pushManager) {
throw new Error('Push notifications not supported on this device')
}
// Subscribe to push with timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Push subscription timed out - this device may not support web push')), 15000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
// Send subscription to server
const response = await fetch('/api/notifications/subscribe', {

View File

@@ -1,5 +1,7 @@
import { format, addHours } from 'date-fns'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
@@ -35,8 +37,19 @@ export function generateICalendar(
'VERSION:2.0',
'PRODID:-//NextStep//Health Management//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
`X-WR-TIMEZONE:${TIMEZONE}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
// Add timezone definition for Australia/Perth
'BEGIN:VTIMEZONE',
`TZID:${TIMEZONE}`,
'BEGIN:STANDARD',
'DTSTART:19700101T000000',
'TZOFFSETFROM:+0800',
'TZOFFSETTO:+0800',
'TZNAME:AWST',
'END:STANDARD',
'END:VTIMEZONE',
]
for (const appt of appointments) {
@@ -45,9 +58,9 @@ export function generateICalendar(
lines.push('BEGIN:VEVENT')
lines.push(`UID:${appt.id}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
if (appt.location) {
@@ -116,9 +129,9 @@ export function generateMedicationEvents(
lines.push('BEGIN:VEVENT')
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
lines.push('CATEGORIES:MEDICATION')
@@ -161,11 +174,16 @@ function getMedicationTimes(med: Medication): string[] {
}
}
function formatICalDate(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ
function formatICalDateUTC(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ (UTC)
return format(date, "yyyyMMdd'T'HHmmss'Z'")
}
function formatICalDateLocal(date: Date): string {
// Format: YYYYMMDDTHHMMSS (local time, no Z suffix)
return format(date, "yyyyMMdd'T'HHmmss")
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')

View File

@@ -133,6 +133,11 @@ function calculateIntervalDue(
// Calculate how many intervals have passed since start time today
const minutesSinceStart = differenceInMinutes(now, startToday)
const intervalMinutes = schedule.hours * 60
if (intervalMinutes <= 0) {
return startToday
}
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)