commit a32c609830f58509c25eced241788e1e7d740691 Author: Gemini Agent Date: Sun Jan 18 23:16:45 2026 +0000 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd75015 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next +out + +# Production +build + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Misc +README.md +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a02e45c --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Database +DATABASE_URL="postgresql://nextstep:nextstep@localhost:5432/nextstep?schema=public" + +# App Configuration +NEXTAUTH_SECRET="generate-a-random-secret-here-at-least-32-chars" +NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# Timezone (important for medication scheduling) +TZ="Australia/Perth" + +# Rate Limiting +RATE_LIMIT_MAX_REQUESTS=100 +RATE_LIMIT_WINDOW_MS=60000 + +# Login Security +LOGIN_MAX_ATTEMPTS=5 +LOGIN_LOCKOUT_MINUTES=15 + +# Session +SESSION_MAX_AGE_DAYS=30 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3017db4 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "react/no-unescaped-entities": "off" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc567f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js +/.next/ +/out/ + +# Production +/build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env +.env*.local + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6192c63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app + +COPY package.json package-lock.json* ./ +COPY prisma ./prisma/ + +RUN npm ci + +# Stage 2: Builder +FROM node:20-alpine AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the application +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# Stage 3: Runner +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TZ=Australia/Perth + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Install tzdata for timezone support +RUN apk add --no-cache tzdata + +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Run database migrations before starting +CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ffde63 --- /dev/null +++ b/README.md @@ -0,0 +1,318 @@ +# Next Step + +A calm, reliable health management app for families supporting a loved one through treatment. Built to be "mum-proof" — simple, clear, and accessible. + +![Next Step](https://via.placeholder.com/800x400/3a9563/ffffff?text=Next+Step) + +## Features + +### Today Dashboard +- **Next appointment** with location and map link +- **Medications due** with one-tap "Taken" button +- **Quick note** for jotting down thoughts +- **Call clinic** button for easy access + +### Appointments +- Simple timeline view with date groupings +- Add title, date/time, location, map link, and notes +- Soft delete with recovery + +### Medications +- **Multiple schedule types:** + - Fixed times daily (e.g., 8am, 8pm) + - Every X hours (e.g., every 8 hours) + - Specific weekdays (e.g., Mon/Wed/Fri at 9am) + - PRN/As needed with cooldown period +- One-tap dose logging with 5-minute undo window +- "What did I take?" history view (last 7 days) +- Overdue indicators with grace period + +### Notes +- **Questions for doctor** — track what to ask, mark as asked +- **General notes** — timestamped thoughts +- Copy questions for appointments + +### Family Sharing +- Workspace model (e.g., "Grace's Plan") +- Invite family via link +- Roles: Owner, Editor, Viewer +- Audit log of all changes + +### Offline-First +- Works without internet connection +- IndexedDB local cache +- Automatic sync when online +- Conflict detection with "updated on another device" banner + +## Tech Stack + +- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS +- **Database:** PostgreSQL with Prisma ORM +- **Auth:** Session cookies with argon2 password hashing +- **Offline:** IndexedDB via Dexie.js +- **Deployment:** Docker Compose + +## Quick Start + +### Prerequisites +- Docker and Docker Compose +- Tailscale (for external access) + +### 1. Clone and Configure + +```bash +cd /path/to/nextstep + +# Copy environment template +cp .env.example .env + +# Edit .env and set: +# - NEXTAUTH_SECRET (generate with: openssl rand -base64 32) +# - DB_PASSWORD (choose a secure password) +# - NEXT_PUBLIC_APP_URL (your Tailscale Funnel URL) +``` + +### 2. Start the Application + +```bash +docker compose up -d +``` + +The app will: +1. Build the Next.js application +2. Start PostgreSQL +3. Run database migrations +4. Start the app on `127.0.0.1:3000` + +### 3. Set Up Tailscale Funnel + +Tailscale Funnel exposes your local app to the internet with automatic HTTPS. + +```bash +# Enable Funnel (one-time setup) +tailscale funnel --https=443 http://127.0.0.1:3000 --bg + +# Check status +tailscale funnel status + +# Your app is now accessible at: +# https://[your-machine-name].[your-tailnet].ts.net +``` + +### 4. Update Your Environment + +Edit `.env` and set `NEXT_PUBLIC_APP_URL` to your Funnel URL: + +``` +NEXT_PUBLIC_APP_URL=https://your-machine.your-tailnet.ts.net +``` + +Then restart the app: + +```bash +docker compose down +docker compose up -d +``` + +### 5. Create Your Account + +1. Open your Funnel URL in a browser +2. Click "Create Account" +3. Accept the disclaimer +4. Create your workspace (e.g., "Grace's Plan") +5. Add your clinic phone number + +## Development + +### Local Development + +```bash +# Install dependencies +npm install + +# Set up local PostgreSQL (or use Docker) +docker run -d \ + --name nextstep-postgres \ + -e POSTGRES_USER=nextstep \ + -e POSTGRES_PASSWORD=nextstep \ + -e POSTGRES_DB=nextstep \ + -p 5432:5432 \ + postgres:16-alpine + +# Set up environment +cp .env.example .env +# Edit .env with DATABASE_URL=postgresql://nextstep:nextstep@localhost:5432/nextstep + +# Generate Prisma client +npm run db:generate + +# Run migrations +npm run db:migrate + +# Start dev server +npm run dev +``` + +### Running Tests + +```bash +npm test +``` + +Tests cover: +- Medication scheduling logic +- PRN cooldown calculations +- Fixed times, interval, and weekday schedules + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `DATABASE_URL` | PostgreSQL connection string | Yes | +| `NEXTAUTH_SECRET` | Session encryption secret (min 32 chars) | Yes | +| `NEXT_PUBLIC_APP_URL` | Public URL of the app | Yes | +| `DB_PASSWORD` | PostgreSQL password (for Docker) | Yes | +| `TZ` | Timezone (default: Australia/Perth) | No | +| `RATE_LIMIT_MAX_REQUESTS` | Max requests per minute (default: 100) | No | +| `LOGIN_MAX_ATTEMPTS` | Failed logins before lockout (default: 5) | No | +| `LOGIN_LOCKOUT_MINUTES` | Lockout duration (default: 15) | No | +| `SESSION_MAX_AGE_DAYS` | Session lifetime (default: 30) | No | + +## API Endpoints + +### Authentication +- `POST /api/auth/register` — Create account +- `POST /api/auth/login` — Sign in +- `POST /api/auth/logout` — Sign out +- `GET /api/auth/me` — Get current user + +### Workspaces +- `GET /api/workspaces` — List user's workspaces +- `POST /api/workspaces` — Create workspace +- `GET /api/workspaces/[id]` — Get workspace details +- `PATCH /api/workspaces/[id]` — Update workspace settings +- `POST /api/workspaces/[id]/invite` — Create invite link +- `GET /api/invite/[token]` — Get invite details +- `POST /api/invite/[token]` — Accept invite + +### Sync +- `GET /api/sync?workspaceId=...&since=...` — Pull changes +- `POST /api/sync` — Push offline operations + +### Health +- `GET /api/health` — Health check + +## Security + +- **Password hashing:** Argon2id with secure parameters +- **Session cookies:** HTTPOnly, Secure, SameSite=Lax +- **Rate limiting:** Per-IP request limits +- **Login protection:** Lockout after failed attempts +- **Input validation:** Zod schemas on all endpoints +- **HTTPS:** Enforced via Tailscale Funnel + +## Tailscale Funnel Commands + +```bash +# Start Funnel (background mode) +tailscale funnel --https=443 http://127.0.0.1:3000 --bg + +# Check Funnel status +tailscale funnel status + +# Stop Funnel +tailscale funnel off + +# View Funnel logs +tailscale funnel status --json +``` + +## Backup & Restore + +### Backup Database + +```bash +docker exec nextstep-db pg_dump -U nextstep nextstep > backup.sql +``` + +### Restore Database + +```bash +cat backup.sql | docker exec -i nextstep-db psql -U nextstep nextstep +``` + +### Export User Data + +Users can export their data as JSON from Settings > Export Data. + +## Troubleshooting + +### App won't start + +```bash +# Check logs +docker compose logs app + +# Common issues: +# - Database not ready: wait a few seconds, it will retry +# - Missing env vars: check .env file +``` + +### Database connection failed + +```bash +# Check database is running +docker compose ps + +# Check database logs +docker compose logs db + +# Verify connection string in .env +``` + +### Tailscale Funnel not working + +```bash +# Ensure Funnel is enabled for your tailnet +# (requires admin access to Tailscale admin console) + +# Check if Funnel is running +tailscale funnel status + +# Restart Funnel +tailscale funnel off +tailscale funnel --https=443 http://127.0.0.1:3000 --bg +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tailscale Funnel │ +│ (HTTPS termination) │ +└────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Next.js App │ +│ (127.0.0.1:3000) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ App │ │ API │ │ Auth │ │ +│ │ Router │ │ Routes │ │ (Sessions) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────┘ │ +└────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ (Internal only) │ +└─────────────────────────────────────────────────────────┘ +``` + +## License + +MIT License. See LICENSE file for details. + +## Disclaimer + +**Next Step is a tracking tool only.** It does not provide medical advice. Always consult your healthcare team for medical decisions. For emergencies, call 000 (Australia) or your local emergency services. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1adc817 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: nextstep-app + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" # Bind to localhost only for Tailscale Funnel + environment: + - DATABASE_URL=postgresql://nextstep:${DB_PASSWORD:-nextstep}@db:5432/nextstep?schema=public + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - TZ=Australia/Perth + - NODE_ENV=production + depends_on: + db: + condition: service_healthy + networks: + - nextstep-network + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + db: + image: postgres:16-alpine + container_name: nextstep-db + restart: unless-stopped + environment: + - POSTGRES_USER=nextstep + - POSTGRES_PASSWORD=${DB_PASSWORD:-nextstep} + - POSTGRES_DB=nextstep + - TZ=Australia/Perth + - PGTZ=Australia/Perth + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - nextstep-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextstep -d nextstep"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + # Do not expose PostgreSQL to the host - only accessible within the network + # If you need direct access, uncomment below: + # ports: + # - "127.0.0.1:5432:5432" + +volumes: + postgres_data: + name: nextstep-postgres-data + +networks: + nextstep-network: + name: nextstep-network + driver: bridge diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..f255525 --- /dev/null +++ b/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverActions: { + bodySizeLimit: '2mb', + }, + }, + // Output standalone for Docker + output: 'standalone', +} + +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac93dfa --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "next-step", + "version": "1.0.0", + "description": "Family health management app for chemo patients", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:migrate:deploy": "prisma migrate deploy", + "db:seed": "tsx prisma/seed.ts", + "postinstall": "prisma generate" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "argon2": "^0.41.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "dexie": "^4.0.10", + "dexie-react-hooks": "^1.1.7", + "lucide-react": "^0.468.0", + "nanoid": "^5.0.9", + "next": "^14.2.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.9", + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.21", + "postcss": "^8.4.49", + "prisma": "^5.22.0", + "tailwindcss": "^3.4.17", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d9b73ea --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,307 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// USER & AUTHENTICATION +// ============================================ + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + sessions Session[] + workspaceMembers WorkspaceMember[] + createdAppointments Appointment[] @relation("AppointmentCreatedBy") + updatedAppointments Appointment[] @relation("AppointmentUpdatedBy") + createdMedications Medication[] @relation("MedicationCreatedBy") + updatedMedications Medication[] @relation("MedicationUpdatedBy") + createdNotes Note[] @relation("NoteCreatedBy") + updatedNotes Note[] @relation("NoteUpdatedBy") + loggedDoses DoseLog[] @relation("DoseLoggedBy") + undoneDoses DoseLog[] @relation("DoseUndoneBy") + auditLogs AuditLog[] + + @@index([email]) +} + +model Session { + id String @id @default(cuid()) + userId String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + userAgent String? + ipAddress String? + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) + @@index([expiresAt]) +} + +model LoginAttempt { + id String @id @default(cuid()) + email String + ipAddress String? + success Boolean + createdAt DateTime @default(now()) + + @@index([email, createdAt]) + @@index([ipAddress, createdAt]) +} + +// ============================================ +// WORKSPACE & SHARING +// ============================================ + +model Workspace { + id String @id @default(cuid()) + name String // e.g., "Grace's Plan" + clinicPhone String? + emergencyPhone String? + quietHoursStart String? // HH:mm format + quietHoursEnd String? // HH:mm format + largeTextMode Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + members WorkspaceMember[] + inviteTokens InviteToken[] + appointments Appointment[] + medications Medication[] + notes Note[] + doseLogs DoseLog[] + auditLogs AuditLog[] + syncCursors SyncCursor[] + + @@index([name]) +} + +enum WorkspaceRole { + OWNER + EDITOR + VIEWER +} + +model WorkspaceMember { + id String @id @default(cuid()) + workspaceId String + userId String + role WorkspaceRole @default(VIEWER) + createdAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([workspaceId, userId]) + @@index([userId]) +} + +model InviteToken { + id String @id @default(cuid()) + workspaceId String + token String @unique + role WorkspaceRole @default(VIEWER) + expiresAt DateTime + usedAt DateTime? + usedById String? + createdAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([workspaceId]) +} + +// ============================================ +// APPOINTMENTS +// ============================================ + +model Appointment { + id String @id @default(cuid()) + workspaceId String + title String + datetime DateTime + location String? + mapUrl String? + notes String? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String + updatedById String + + // Sync tracking + version Int @default(1) + syncedAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("AppointmentCreatedBy", fields: [createdById], references: [id]) + updatedBy User @relation("AppointmentUpdatedBy", fields: [updatedById], references: [id]) + + @@index([workspaceId, datetime]) + @@index([workspaceId, deletedAt]) + @@index([syncedAt]) +} + +// ============================================ +// MEDICATIONS +// ============================================ + +enum ScheduleType { + FIXED_TIMES // e.g., 08:00, 20:00 daily + INTERVAL // every X hours + WEEKDAYS // specific weekdays at a time + PRN // as needed with min hours between +} + +model Medication { + id String @id @default(cuid()) + workspaceId String + name String + instructions String? + scheduleType ScheduleType + scheduleData Json // Flexible storage for schedule details + startDate DateTime? + endDate DateTime? + active Boolean @default(true) + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String + updatedById String + + // Sync tracking + version Int @default(1) + syncedAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("MedicationCreatedBy", fields: [createdById], references: [id]) + updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id]) + doseLogs DoseLog[] + + @@index([workspaceId, active]) + @@index([workspaceId, deletedAt]) + @@index([syncedAt]) +} + +// Schedule data shapes (stored as JSON): +// FIXED_TIMES: { times: ["08:00", "20:00"] } +// INTERVAL: { hours: 8, startTime: "08:00" } +// WEEKDAYS: { days: [1, 3, 5], time: "09:00" } // Monday, Wednesday, Friday +// PRN: { minHoursBetween: 4 } + +model DoseLog { + id String @id @default(cuid()) + medicationId String + workspaceId String + takenAt DateTime + loggedById String + undoneAt DateTime? + undoneById String? + createdAt DateTime @default(now()) + + // Sync tracking (append-only, never overwritten) + syncedAt DateTime @default(now()) + + // Relations + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + loggedBy User @relation("DoseLoggedBy", fields: [loggedById], references: [id]) + undoneBy User? @relation("DoseUndoneBy", fields: [undoneById], references: [id]) + + @@index([medicationId, takenAt]) + @@index([workspaceId, takenAt]) + @@index([syncedAt]) +} + +// ============================================ +// NOTES +// ============================================ + +enum NoteType { + QUESTION // Questions for doctor + GENERAL // General notes +} + +model Note { + id String @id @default(cuid()) + workspaceId String + type NoteType + content String + askedAt DateTime? // When a question was asked (marked) + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String + updatedById String + + // Sync tracking + version Int @default(1) + syncedAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("NoteCreatedBy", fields: [createdById], references: [id]) + updatedBy User @relation("NoteUpdatedBy", fields: [updatedById], references: [id]) + + @@index([workspaceId, type]) + @@index([workspaceId, deletedAt]) + @@index([syncedAt]) +} + +// ============================================ +// AUDIT LOG +// ============================================ + +model AuditLog { + id String @id @default(cuid()) + workspaceId String + userId String + action String // CREATE, UPDATE, DELETE, TAKE_DOSE, UNDO_DOSE, etc. + entityType String // APPOINTMENT, MEDICATION, NOTE, DOSE_LOG + entityId String + details Json? // Additional context + createdAt DateTime @default(now()) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@index([workspaceId, createdAt]) + @@index([entityType, entityId]) +} + +// ============================================ +// SYNC +// ============================================ + +model SyncCursor { + id String @id @default(cuid()) + workspaceId String + cursor BigInt @default(0) // Timestamp-based cursor + updatedAt DateTime @updatedAt + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@unique([workspaceId]) +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..1ddae5a --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Next Step", + "short_name": "Next Step", + "description": "Health management made simple", + "start_url": "/today", + "display": "standalone", + "background_color": "#fafbfc", + "theme_color": "#3a9563", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/src/app/(app)/appointments/new/page.tsx b/src/app/(app)/appointments/new/page.tsx new file mode 100644 index 0000000..3f6c74f --- /dev/null +++ b/src/app/(app)/appointments/new/page.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { format } from 'date-fns' +import { toZonedTime } from 'date-fns-tz' +import { Button, Input, Textarea, Card, showToast } from '@/components/ui' +import { Header, PageContainer } from '@/components/layout/header' +import { useApp } from '../../provider' + +const TIMEZONE = 'Australia/Perth' + +export default function NewAppointmentPage() { + const router = useRouter() + const { currentWorkspace, refreshData } = useApp() + + const [title, setTitle] = useState('') + const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd')) + const [time, setTime] = useState('09:00') + const [location, setLocation] = useState('') + const [mapUrl, setMapUrl] = useState('') + const [notes, setNotes] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + // Combine date and time + const datetime = new Date(`${date}T${time}:00`) + + const response = await fetch( + `/api/workspaces/${currentWorkspace.id}/appointments`, + { + method: 'POST', + 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 create appointment') + } + + await refreshData() + showToast('Appointment added', 'success') + router.push('/appointments') + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setLoading(false) + } + } + + return ( + <> +
+ + +
+ setTitle(e.target.value)} + placeholder="e.g., Oncology Appointment" + required + /> + +
+ setDate(e.target.value)} + required + /> + setTime(e.target.value)} + required + /> +
+ + setLocation(e.target.value)} + placeholder="e.g., Level 3, Cancer Centre" + /> + + setMapUrl(e.target.value)} + placeholder="https://maps.google.com/..." + helperText="Paste a Google Maps or Apple Maps link" + /> + +