mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c82ba4b33e | |||
| 130f90bf6a | |||
| 403e0e38e9 | |||
| 2454bb4c2b | |||
| 6bd81bd7dd | |||
| a68b7f2573 | |||
| 014b4ddf8f | |||
| f10d01546b | |||
| 4e60361f77 | |||
| 7a359d4911 | |||
| 2d47abcc42 | |||
| b74d5d2d05 | |||
| 90e6ec4cc0 | |||
| 516de6ad60 | |||
| e656eae362 | |||
| bbc6869745 | |||
| 1d9b1b1d22 | |||
| 913a217e1d | |||
| e841cb56e1 | |||
| c88b674196 | |||
| 0fa2802723 | |||
| 2f36875d37 | |||
| eb94c1549a | |||
| dfab71492c | |||
| 1c52dcaf84 | |||
| 5cbc7411c0 | |||
| e43409ac14 |
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@@ -0,0 +1,68 @@
|
||||
# Builds and publishes the temetro Docker images and cuts a GitHub Release.
|
||||
#
|
||||
# Trigger: push a semver tag, e.g.
|
||||
# git tag v0.1.0 && git push origin v0.1.0
|
||||
#
|
||||
# The frontend image bakes NO API URL — it resolves the backend from the host
|
||||
# the browser uses at runtime — so one published image works for every clinic.
|
||||
#
|
||||
# Required repository secrets (Settings → Secrets and variables → Actions):
|
||||
# DOCKERHUB_USERNAME — the Docker Hub account `khalidxv` (or one with push
|
||||
# access to that namespace)
|
||||
# DOCKERHUB_TOKEN — a Docker Hub access token for that account
|
||||
# Without them the build/login step fails; the workflow is otherwise inert.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write # create the GitHub Release (the update check reads this)
|
||||
|
||||
env:
|
||||
REGISTRY_NAMESPACE: khalidxv
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive version from tag
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
|
||||
|
||||
- name: Build & push frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to temetro are recorded here. The format is loosely based on
|
||||
[Keep a Changelog](https://keepachangelog.com/), and the project follows
|
||||
[Semantic Versioning](https://semver.org/). See [RELEASING.md](./RELEASING.md)
|
||||
for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Version & update awareness.** `GET /api/version` reports the running version
|
||||
and checks GitHub Releases for a newer one; Settings → **About & updates** shows
|
||||
the current/latest version, and an optional, dismissible banner appears when an
|
||||
update is available.
|
||||
- **LAN access.** The frontend now resolves the backend URL from the host the
|
||||
browser is using, so other departments can reach temetro at
|
||||
`http://<server-LAN-IP>:3000` with no rebuild. A Settings panel surfaces the
|
||||
shareable network address. `GET /api/network` reports detected LAN addresses.
|
||||
- **Prebuilt Docker images** published to Docker Hub (`khalidxv/temetro-backend`,
|
||||
`khalidxv/temetro-frontend`) via a tag-triggered GitHub Actions release workflow.
|
||||
- **Voice dictation** on the AI chat input (Web Speech API), with graceful
|
||||
fallback where the browser doesn't support it.
|
||||
|
||||
### Changed
|
||||
- `docker-compose.yml` references the published images (with a build fallback) and
|
||||
no longer bakes a fixed API URL into the frontend.
|
||||
|
||||
## [0.1.0] — 2026-06-26
|
||||
|
||||
Initial baseline: clinician AI-chat UI wired to the TypeScript/Express/Postgres
|
||||
API (Better Auth, multi-tenant clinics, org-scoped patient records), the patient
|
||||
wallet encrypted-share flow, and Dockerised local run.
|
||||
@@ -23,10 +23,29 @@ repository (published as `temetro`).
|
||||
> (login/signup/reset/onboarding), route protection, clinic switching, and patient data fetched
|
||||
> over the API — the old in-memory fixture is gone (`frontend/lib/patients.ts` now calls the backend).
|
||||
>
|
||||
> **Still vision, not built:** the patient companion app and the blockchain-style **signing /
|
||||
> patient-owned storage / approval** flow. The AI chat itself is still **mock replies** (no LLM
|
||||
> call yet — a `/chat` endpoint is the next planned step). Email verification is wired but
|
||||
> currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
> **Now built (thin slice):** a **patient wallet app** (`~/Desktop/temetro-app`, sibling repo — see
|
||||
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
|
||||
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
|
||||
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
|
||||
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
|
||||
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
>
|
||||
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
|
||||
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
|
||||
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
|
||||
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
|
||||
|
||||
The **patient companion app** is its own git repo on the Desktop (not in this monorepo): an **Expo
|
||||
SDK 56** app whose UI **must be built with HeroUI Native** (`heroui-native` + Uniwind/Tailwind) — a
|
||||
hard requirement; the one exception is the native tab bar, which uses expo-router `NativeTabs`. See
|
||||
its `CLAUDE.md`. It stores the patient's record **encrypted on-device**,
|
||||
the patient's identity is an **Ed25519 keypair** whose public key (base58check, `tmw_…`) is their
|
||||
**wallet number**, and it shares records by sealing them to a clinic's ephemeral key over the
|
||||
backend relay. The crypto wire format mirrors `backend/src/lib/wallet-crypto.ts` exactly. "Decentralization"
|
||||
here means keys + data live on the patient's device and the relay only ever forwards ciphertext — it
|
||||
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
|
||||
deleted). Commit/push that app inside its own repo, separately from this one.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -1,97 +1,141 @@
|
||||
<div align="center">
|
||||
|
||||
# temetro
|
||||
|
||||
**temetro** is an **open-source** clinical tool that acts as an **AI middleman** between clinicians
|
||||
and patient data. Clinicians use a natural-language AI chat to retrieve and organize patient
|
||||
information, displayed as rich record cards.
|
||||
**An open-source AI middleman between clinicians and patient data.**
|
||||
|
||||
Its distinguishing idea is a **patient-owned data model**: instead of (or alongside) living in a
|
||||
doctor's own database, a patient's record can be stored on the **patient's own device**. When a
|
||||
clinician adds or changes data, they **sign** it (blockchain-style); the change is written to the
|
||||
patient's record and **cannot be modified until the patient approves it** through a companion app.
|
||||
temetro can also **read existing patient databases** and present them in the same organized card UI.
|
||||
Clinicians ask in plain language; temetro retrieves and organizes patient
|
||||
information as rich record cards — backed by a **patient-owned data model**.
|
||||
|
||||
> **Status.** The **backend is built** — a TypeScript + Express + Postgres API (Drizzle ORM) with
|
||||
> authentication and multi-tenant clinics via [Better Auth](https://better-auth.com), an org-scoped
|
||||
> patient records API, plus appointments, prescriptions, tasks, doctor's notes, analytics, an
|
||||
> activity audit log, real-time staff messaging (Socket.io), and notifications. The **frontend chat
|
||||
> is wired to it** (real auth, route protection, clinic switching, live patient data).
|
||||
>
|
||||
> **Still vision, not built:** the patient companion app and the blockchain-style
|
||||
> **signing / patient-owned storage / approval** flow, and the AI chat replies themselves (currently
|
||||
> mock — no LLM call yet; a `/chat` endpoint is the next planned step).
|
||||
[](./LICENSE)
|
||||
[](https://hub.docker.com/u/khalidxv)
|
||||
[](./CHANGELOG.md)
|
||||
|
||||
## Monorepo layout
|
||||

|
||||
|
||||
This repository is a **monorepo** containing two independent apps that live side by side (each with
|
||||
its own `package.json` / `node_modules` and its own `CLAUDE.md`):
|
||||
</div>
|
||||
|
||||
- **[`frontend/`](./frontend)** — the Next.js 16 product app (the clinician-facing AI chat UI).
|
||||
See [`frontend/CLAUDE.md`](./frontend/CLAUDE.md).
|
||||
- **[`backend/`](./backend)** — the Express 5 + Postgres API (Drizzle ORM + Better Auth).
|
||||
See [`backend/CLAUDE.md`](./backend/CLAUDE.md) and [`backend/README.md`](./backend/README.md).
|
||||
## What is temetro?
|
||||
|
||||
The marketing **landing page lives in its own separate repository** (`temetro-landing`), not here.
|
||||
temetro is a clinical tool that puts a **natural-language AI chat** between
|
||||
clinicians and patient records. Instead of clicking through tabs, a clinician
|
||||
asks for what they need and temetro returns organized **record cards** —
|
||||
vitals, labs, medications, problems, encounters — with trend sparklines and
|
||||
detail views.
|
||||
|
||||
## Run locally with Docker (recommended)
|
||||
Its distinguishing idea is a **patient-owned data model**. Instead of (or
|
||||
alongside) living only in a doctor's database, a patient's record can live on
|
||||
the **patient's own device**. When a clinician adds or changes data they
|
||||
**sign** it (blockchain-style); the change is written to the patient's record
|
||||
and **cannot be modified until the patient approves it** through a companion
|
||||
wallet app. temetro can also **read existing patient databases** and present
|
||||
them in the same card UI.
|
||||
|
||||
Docker Compose builds and runs Postgres, the backend, and the frontend together. From the
|
||||
**`backend/`** directory (the Compose file builds the sibling `../frontend`):
|
||||
> "Decentralization" here means keys and data live on the patient's device and
|
||||
> the relay only ever forwards ciphertext — it is **not** a literal blockchain.
|
||||
> Records are off-chain, which is what lets a temporary share be deleted.
|
||||
|
||||
## Features
|
||||
|
||||
- 🗂️ **AI chat over patient records** — `/patient <file#>` (or natural language)
|
||||
renders the record as cards with sparklines and detail dialogs.
|
||||
- 🔐 **Auth & multi-tenant clinics** — email/password + staff usernames,
|
||||
organizations, and role-based access (owner / admin / doctor / reception /
|
||||
pharmacy / lab) via [Better Auth](https://better-auth.com).
|
||||
- 🩺 **Full clinical surface** — patients, appointments, prescriptions, tasks,
|
||||
doctor's notes, pharmacy inventory, analytics, an activity audit log,
|
||||
real-time staff messaging, and notifications.
|
||||
- 📲 **Patient wallet & encrypted share** — a companion app holds the record
|
||||
encrypted on-device; clinics import records over an end-to-end encrypted,
|
||||
patient-approved relay (with optional auto-deleting temporary shares).
|
||||
- 🌐 **Works across the clinic LAN** — open it on the server or from any
|
||||
department's computer at `http://<server-IP>:3000`; no per-machine config.
|
||||
- 🔄 **Self-update awareness** — the app tells admins when a newer release is
|
||||
out and how to update.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
The fastest path uses the **prebuilt images** published to Docker Hub. From the
|
||||
[`backend/`](./backend) directory (it holds the Compose file):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
|
||||
# generate a strong auth secret and paste it into BETTER_AUTH_SECRET in .env:
|
||||
openssl rand -base64 32
|
||||
|
||||
docker compose up --build
|
||||
docker compose pull # fetch the latest published images
|
||||
docker compose up -d # start Postgres + backend + frontend
|
||||
```
|
||||
|
||||
Then open:
|
||||
No `.env` or secret setup is required — the backend generates and persists any
|
||||
missing secrets on first start. Then open:
|
||||
|
||||
- Frontend → http://localhost:3000
|
||||
- Backend → http://localhost:4000 (health check: `GET /health`)
|
||||
- Postgres → localhost:5432
|
||||
- **Frontend** → http://localhost:3000
|
||||
- **Backend** → http://localhost:4000 (health: `GET /health`)
|
||||
|
||||
Database migrations are applied automatically on backend container start.
|
||||
Prefer to **build from source** (for development)? Use `docker compose up
|
||||
--build` instead. Migrations apply automatically on backend start.
|
||||
|
||||
**Port conflict?** If another Postgres already holds host port `5432`, set `POSTGRES_PORT` (e.g.
|
||||
`5433`) in `backend/.env`. The app still talks to Postgres internally on `db:5432`; only the
|
||||
published host port changes.
|
||||
> **Port conflict?** If another Postgres holds host port `5432`, set
|
||||
> `POSTGRES_PORT` (e.g. `5433`) in `backend/.env`. The app still talks to
|
||||
> Postgres internally on `db:5432`; only the published host port changes.
|
||||
|
||||
Run just the API + database with `docker compose up db backend`. To browse the database with
|
||||
Adminer: `docker compose --profile tools up adminer` → http://localhost:8080.
|
||||
### Access from other computers (hospital LAN)
|
||||
|
||||
## Run locally without Docker
|
||||
temetro figures out the backend address from the host you open it on, so other
|
||||
departments can simply visit **`http://<server-LAN-IP>:3000`** — no rebuild
|
||||
needed. Settings → **About & updates** shows the exact shareable address. The
|
||||
server's firewall must allow ports `3000`/`4000`.
|
||||
|
||||
Run each app in its own terminal (you'll need a local Postgres reachable from `DATABASE_URL`):
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
The app surfaces a notification when a newer release exists. See
|
||||
[`CHANGELOG.md`](./CHANGELOG.md) for what changed and [`RELEASING.md`](./RELEASING.md)
|
||||
for how releases are built and published.
|
||||
|
||||
## Run without Docker
|
||||
|
||||
Each app runs independently (you'll need a local Postgres in `DATABASE_URL`):
|
||||
|
||||
```bash
|
||||
# backend
|
||||
cd backend
|
||||
npm install
|
||||
cp .env.example .env # point DATABASE_URL at your local Postgres
|
||||
npm run db:migrate # apply migrations
|
||||
npm run dev # http://localhost:4000
|
||||
cd backend && npm install && cp .env.example .env
|
||||
npm run db:migrate && npm run dev # http://localhost:4000
|
||||
|
||||
# frontend (second terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
cd frontend && npm install && npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
The frontend reads `NEXT_PUBLIC_API_URL` (default `http://localhost:4000`) to reach the backend.
|
||||
> With `SMTP_HOST` unset, verification / reset / invitation emails are **printed
|
||||
> to the backend console** — no email setup needed for local development.
|
||||
|
||||
> If `SMTP_HOST` is unset, verification / reset / invitation emails are **printed to the backend
|
||||
> console** instead of being sent — no email setup is needed for local development.
|
||||
## Monorepo layout
|
||||
|
||||
Two independent apps live side by side, each with its own `package.json` and
|
||||
`CLAUDE.md`:
|
||||
|
||||
- **[`frontend/`](./frontend)** — Next.js 16 product app (the AI chat UI).
|
||||
- **[`backend/`](./backend)** — Express 5 + Postgres API (Drizzle ORM + Better
|
||||
Auth). See [`backend/README.md`](./backend/README.md) for the API reference.
|
||||
|
||||
The **patient wallet app** and the **marketing landing page** live in their own
|
||||
separate repositories.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Frontend:** Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · i18next ·
|
||||
Socket.io client.
|
||||
- **Backend:** Node ≥ 20 · TypeScript (ESM) · Express 5 · Postgres · Drizzle ORM · Better Auth
|
||||
(email/password + organizations/RBAC) · Socket.io.
|
||||
- **Frontend:** Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4
|
||||
· i18next · Socket.io client.
|
||||
- **Backend:** Node ≥ 20 · TypeScript (ESM) · Express 5 · Postgres · Drizzle ORM
|
||||
· Better Auth (email/password + organizations/RBAC) · Socket.io · AI SDK.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Please keep each PR focused on one logical
|
||||
change and run the type checks (`npm run typecheck` in `backend/`, `npx tsc
|
||||
--noEmit` in `frontend/`) before opening it. Bug reports and feature requests
|
||||
have [issue templates](./.github/ISSUE_TEMPLATE).
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
[MIT](./LICENSE).
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Releasing temetro
|
||||
|
||||
temetro is self-hosted: clinics run it with Docker and update by pulling new
|
||||
images. Each release publishes prebuilt images to Docker Hub and cuts a GitHub
|
||||
Release; the running app checks that release feed to tell admins an update is
|
||||
available. This document is the checklist for cutting one.
|
||||
|
||||
## Versioning
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.PATCH`,
|
||||
starting at **0.1.0**. The canonical version lives in the root
|
||||
[`package.json`](./package.json); `backend/package.json` and
|
||||
`frontend/package.json` track the same number. `GET /api/version` reports it.
|
||||
|
||||
## One-time setup
|
||||
|
||||
Add these repository secrets (GitHub → Settings → Secrets and variables →
|
||||
Actions) so the release workflow can publish:
|
||||
|
||||
| Secret | What |
|
||||
| --- | --- |
|
||||
| `DOCKERHUB_USERNAME` | Docker Hub account with push access to the `khalidxv` namespace |
|
||||
| `DOCKERHUB_TOKEN` | A Docker Hub access token for that account |
|
||||
|
||||
Images are published as `khalidxv/temetro-backend` and `khalidxv/temetro-frontend`.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. **Bump the version** in `package.json`, `backend/package.json`, and
|
||||
`frontend/package.json` to the new `X.Y.Z`.
|
||||
2. **Update [`CHANGELOG.md`](./CHANGELOG.md)**: move the `Unreleased` notes under a
|
||||
new `## [X.Y.Z] — <date>` heading.
|
||||
3. **Commit** the bump (`chore(release): vX.Y.Z`).
|
||||
4. **Tag and push**:
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
git push origin main --tags
|
||||
```
|
||||
5. The **`release` workflow** (`.github/workflows/release.yml`) then:
|
||||
- builds and pushes both images tagged `X.Y.Z` **and** `latest`, and
|
||||
- creates a **GitHub Release** with auto-generated notes.
|
||||
|
||||
That GitHub Release is what the in-app update check compares against, so update
|
||||
notifications light up for everyone on an older version once it's published.
|
||||
|
||||
## How clinics update
|
||||
|
||||
On the server machine, from the directory holding `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
This pulls the new `latest` images and restarts. Pin a specific version with
|
||||
`TEMETRO_VERSION=X.Y.Z docker compose up -d`. Updating is optional — the in-app
|
||||
banner just makes it discoverable.
|
||||
@@ -30,6 +30,15 @@ NODE_ENV=development
|
||||
# already in use on your machine (the app still talks to Postgres internally).
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# --- Patient wallet relay -------------------------------------------------
|
||||
# The URL baked into the QR a patient scans to import their record. Their phone
|
||||
# must be able to reach it — so localhost will NOT work from a real device. If
|
||||
# unset, the backend derives it from the request host (fine when you open the
|
||||
# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it
|
||||
# explicitly to a phone-reachable address: your machine's LAN IP or a public
|
||||
# tunnel URL.
|
||||
# PUBLIC_RELAY_URL=http://192.168.1.20:4000
|
||||
|
||||
# --- Email (optional) -----------------------------------------------------
|
||||
# If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the
|
||||
# server console instead of being sent — zero setup for local development.
|
||||
|
||||
+10
-1
@@ -9,9 +9,16 @@ shape exactly.
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up # db + backend + frontend — no setup needed
|
||||
docker compose pull # fetch prebuilt images from Docker Hub (fast)
|
||||
docker compose up -d # db + backend + frontend — no setup needed
|
||||
```
|
||||
|
||||
`docker compose up --build` instead builds from source (for development). The
|
||||
Compose file references the published `khalidxv/temetro-backend` and
|
||||
`khalidxv/temetro-frontend` images with a build fallback, so the same file serves
|
||||
both clinics and developers. Update with `docker compose pull && docker compose
|
||||
up -d` (see [`../RELEASING.md`](../RELEASING.md)).
|
||||
|
||||
No `.env` or manual secret generation is required: on first start the backend
|
||||
generates any missing secrets (`BETTER_AUTH_SECRET`, `AI_CREDENTIALS_KEY`) and
|
||||
persists them to a Docker volume, so they stay stable across restarts. Create a
|
||||
@@ -72,6 +79,8 @@ Other org-scoped resources follow the same pattern (CRUD, role-gated):
|
||||
| Analytics | `GET /api/analytics` | — (any member) | computed clinic aggregates |
|
||||
| Conversations | `/api/conversations` | — (participant-scoped) | staff messaging; real-time over Socket.io |
|
||||
| Notifications | `/api/notifications` | — (per-recipient) | auto-generated; `read-all` + per-id read |
|
||||
| Version | `GET /api/version` | — (public) | running version + GitHub-release update check |
|
||||
| Network | `GET /api/network` | — (public) | detected LAN addresses for sharing the app |
|
||||
|
||||
Real-time messaging and live notifications are delivered over **Socket.io**, attached to the same
|
||||
HTTP server; the handshake is authenticated with the Better Auth session cookie.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Overlay that adds a public Cloudflare tunnel so a patient's phone can scan the
|
||||
# wallet-import QR from ANY network (cellular, other Wi-Fi) — not just the LAN.
|
||||
#
|
||||
# npm run docker:tunnel # from backend/ (easiest)
|
||||
# # equivalently:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up --build
|
||||
#
|
||||
# cloudflared opens a random https://<id>.trycloudflare.com URL to the backend
|
||||
# and exposes it on its metrics endpoint; the backend reads it from there and
|
||||
# bakes it into the QR (CLOUDFLARED_METRICS_URL below). Zero config — no
|
||||
# Cloudflare account or token needed for these quick tunnels.
|
||||
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
# --protocol http2 forces the edge connection over TCP/443 instead of QUIC
|
||||
# (UDP/7844), which is blocked on many networks/Docker setups and otherwise
|
||||
# leaves the tunnel stuck "Failed to dial a quic connection".
|
||||
command: tunnel --no-autoupdate --protocol http2 --url http://backend:4000 --metrics 0.0.0.0:3333
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# Where the backend reads its public quick-tunnel URL from.
|
||||
CLOUDFLARED_METRICS_URL: http://cloudflared:3333
|
||||
@@ -1,15 +1,25 @@
|
||||
# Full-stack dev/run orchestration for temetro.
|
||||
#
|
||||
# docker compose up # db + backend + frontend — that's it.
|
||||
# docker compose up -d # run prebuilt images (clinics — fast)
|
||||
# docker compose up --build # build from source (developers)
|
||||
# docker compose pull && docker compose up -d # update to the latest release
|
||||
#
|
||||
# Each service has both `image:` (published on Docker Hub) and `build:` (the
|
||||
# source), so the same file serves clinics pulling releases and developers
|
||||
# building locally. `TEMETRO_VERSION` (default `latest`) pins the image tag.
|
||||
#
|
||||
# No .env or secret setup is required: the backend generates any missing
|
||||
# secrets on first start and persists them (see docker-entrypoint.sh). Create a
|
||||
# .env only if you want to override something (SMTP, a real auth secret, etc.).
|
||||
#
|
||||
# Frontend -> http://localhost:3000
|
||||
# Frontend -> http://localhost:3000 (also reachable at http://<this-host-LAN-IP>:3000)
|
||||
# Backend -> http://localhost:4000
|
||||
# Postgres -> localhost:5432
|
||||
#
|
||||
# LAN access: the frontend finds the backend from the address you open it on, so
|
||||
# other departments can just visit http://<server-LAN-IP>:3000 — no rebuild. The
|
||||
# in-app Settings → "About & updates" page shows the shareable network address.
|
||||
#
|
||||
# The frontend service builds the sibling ../frontend app. Run just the API
|
||||
# with: docker compose up db backend
|
||||
#
|
||||
@@ -36,6 +46,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: khalidxv/temetro-backend:${TEMETRO_VERSION:-latest}
|
||||
build:
|
||||
context: .
|
||||
restart: unless-stopped
|
||||
@@ -68,15 +79,16 @@ services:
|
||||
- "4000:4000"
|
||||
|
||||
frontend:
|
||||
image: khalidxv/temetro-frontend:${TEMETRO_VERSION:-latest}
|
||||
build:
|
||||
context: ../frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
# Empty by default -> the app derives the backend URL from the host the
|
||||
# browser uses (localhost or a LAN IP). Set to pin a fixed/proxied URL.
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "staff_profile" (
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"specialty" text,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "staff_profile" ADD CONSTRAINT "staff_profile_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "staff_profile" ADD CONSTRAINT "staff_profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "staff_profile_org_user_idx" ON "staff_profile" USING btree ("organization_id","user_id");
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "meeting_rooms" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "meeting_rooms" ADD CONSTRAINT "meeting_rooms_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "meeting_rooms" ADD CONSTRAINT "meeting_rooms_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "meeting_rooms_org_idx" ON "meeting_rooms" USING btree ("organization_id");
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "scheduled_meetings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"date" text NOT NULL,
|
||||
"time" text NOT NULL,
|
||||
"participants" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scheduled_meetings" ADD CONSTRAINT "scheduled_meetings_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "scheduled_meetings_org_idx" ON "scheduled_meetings" USING btree ("organization_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks" ADD COLUMN "assignee_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_user_id_user_id_fk" FOREIGN KEY ("assignee_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "email_settings" (
|
||||
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
|
||||
"provider" text DEFAULT 'none' NOT NULL,
|
||||
"from_address" text DEFAULT '' NOT NULL,
|
||||
"credentials" text,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "clinic_signing_keys" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"algorithm" text DEFAULT 'ed25519' NOT NULL,
|
||||
"public_key" text NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"private_key_enc" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"rotated_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wallet_share_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"requested_by" text NOT NULL,
|
||||
"wallet_number" text NOT NULL,
|
||||
"ephemeral_pub_key" text NOT NULL,
|
||||
"ephemeral_priv_enc" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"share_mode" text DEFAULT 'permanent' NOT NULL,
|
||||
"share_expires_at" timestamp,
|
||||
"draft" jsonb,
|
||||
"committed_file_number" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "patients" ADD COLUMN "share_origin" text;--> statement-breakpoint
|
||||
ALTER TABLE "patients" ADD COLUMN "share_expires_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "clinic_signing_keys" ADD CONSTRAINT "clinic_signing_keys_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "wallet_share_org_idx" ON "wallet_share_requests" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "wallet_share_wallet_idx" ON "wallet_share_requests" USING btree ("wallet_number");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "wallet_share_requests" ALTER COLUMN "wallet_number" DROP NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,55 @@
|
||||
"when": 1781802557475,
|
||||
"tag": "0022_damp_synch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1781889806890,
|
||||
"tag": "0023_panoramic_punisher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1781891060177,
|
||||
"tag": "0024_skinny_iron_fist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1781910033543,
|
||||
"tag": "0025_nice_paladin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1781969782874,
|
||||
"tag": "0026_many_wolfsbane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1781973588708,
|
||||
"tag": "0027_romantic_kylun",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1782052852524,
|
||||
"tag": "0028_military_cyclops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1782057030557,
|
||||
"tag": "0029_tiny_starhawk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Fly.io deploy for the temetro backend + wallet relay (deploys ./Dockerfile).
|
||||
#
|
||||
# One-time setup (run from backend/):
|
||||
# fly launch --no-deploy --copy-config # keep this file; pick your app name + region
|
||||
# fly volumes create temetro_data --size 1 # persists auto-generated secrets + uploads
|
||||
# fly postgres create # then:
|
||||
# fly postgres attach <postgres-app-name> # sets DATABASE_URL secret
|
||||
# fly secrets set \
|
||||
# PUBLIC_RELAY_URL=https://<app>.fly.dev \ # baked into the wallet-import QR (must be public https)
|
||||
# BETTER_AUTH_URL=https://<app>.fly.dev \ # https → secure auth cookies
|
||||
# FRONTEND_URL=https://<your-frontend-origin>
|
||||
# fly deploy
|
||||
#
|
||||
# Any other Docker host (Render/Railway/VPS) works with the same env contract;
|
||||
# Fly is just the concrete example.
|
||||
|
||||
app = "temetro-backend" # change to your Fly app name
|
||||
primary_region = "iad"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
PORT = "4000"
|
||||
UPLOAD_DIR = "/var/lib/temetro/uploads"
|
||||
|
||||
# Persists the entrypoint's auto-generated secrets (/var/lib/temetro) and
|
||||
# uploaded files across deploys. Skip this only if you set BETTER_AUTH_SECRET /
|
||||
# AI_CREDENTIALS_KEY as fly secrets instead.
|
||||
[mounts]
|
||||
source = "temetro_data"
|
||||
destination = "/var/lib/temetro"
|
||||
|
||||
[http_service]
|
||||
internal_port = 4000
|
||||
force_https = true
|
||||
# Keep one machine always up — the wallet relay holds live WebSocket
|
||||
# connections, so the app should not sleep mid-pairing.
|
||||
auto_stop_machines = "off"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
|
||||
[[http_service.checks]]
|
||||
method = "get"
|
||||
path = "/health"
|
||||
interval = "15s"
|
||||
timeout = "2s"
|
||||
grace_period = "10s"
|
||||
Generated
+18
@@ -13,6 +13,9 @@
|
||||
"@ai-sdk/google": "^3.0.82",
|
||||
"@ai-sdk/openai": "^3.0.71",
|
||||
"@ai-sdk/openai-compatible": "^2.0.50",
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"ai": "^6.0.204",
|
||||
"better-auth": "^1.6.13",
|
||||
@@ -2183,6 +2186,21 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:tunnel": "node scripts/dev-tunnel.mjs",
|
||||
"docker:tunnel": "docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up --build",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -24,6 +26,9 @@
|
||||
"@ai-sdk/google": "^3.0.82",
|
||||
"@ai-sdk/openai": "^3.0.71",
|
||||
"@ai-sdk/openai-compatible": "^2.0.50",
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"ai": "^6.0.204",
|
||||
"better-auth": "^1.6.13",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 30,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"numReplicas": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Render Blueprint for the temetro backend + wallet relay.
|
||||
#
|
||||
# Render discovers render.yaml at the repo root, so for this monorepo either set
|
||||
# the service's Root Directory to `backend/` in the dashboard, or copy this file
|
||||
# to the repo root and prefix the paths with `backend/`.
|
||||
#
|
||||
# After the first deploy, set the public URL env vars (Render → service →
|
||||
# Environment): PUBLIC_RELAY_URL and BETTER_AUTH_URL to https://<service>.onrender.com,
|
||||
# and FRONTEND_URL to your frontend origin. PUBLIC_RELAY_URL is what gets baked
|
||||
# into the wallet-import QR, so it must be the public https URL.
|
||||
|
||||
databases:
|
||||
- name: temetro-db
|
||||
databaseName: temetro
|
||||
user: temetro
|
||||
plan: free
|
||||
postgresMajorVersion: "17"
|
||||
|
||||
services:
|
||||
- type: web
|
||||
name: temetro-backend
|
||||
runtime: docker
|
||||
dockerfilePath: ./Dockerfile
|
||||
dockerContext: .
|
||||
plan: free
|
||||
healthCheckPath: /health
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
fromDatabase:
|
||||
name: temetro-db
|
||||
property: connectionString
|
||||
- key: NODE_ENV
|
||||
value: production
|
||||
- key: PORT
|
||||
value: "4000"
|
||||
# Generated once and kept stable by Render (no on-disk volume needed).
|
||||
- key: BETTER_AUTH_SECRET
|
||||
generateValue: true
|
||||
- key: AI_CREDENTIALS_KEY
|
||||
generateValue: true
|
||||
# Set these to your public URLs after the first deploy (sync:false = enter
|
||||
# in the dashboard, not committed).
|
||||
- key: PUBLIC_RELAY_URL
|
||||
sync: false
|
||||
- key: BETTER_AUTH_URL
|
||||
sync: false
|
||||
- key: FRONTEND_URL
|
||||
sync: false
|
||||
@@ -0,0 +1,86 @@
|
||||
// Run the backend with a public Cloudflare tunnel so a patient's phone can reach
|
||||
// the wallet relay from ANY network (cellular, other Wi-Fi) — not just the LAN.
|
||||
//
|
||||
// npm run dev:tunnel
|
||||
//
|
||||
// It opens `cloudflared tunnel --url http://localhost:4000`, grabs the public
|
||||
// https://<sub>.trycloudflare.com URL, and starts the backend with
|
||||
// PUBLIC_RELAY_URL set to it — so the QR in "Import from a patient app" carries
|
||||
// that public URL (over wss://). Requires cloudflared: `brew install cloudflared`.
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
|
||||
const PORT = process.env.PORT || "4000";
|
||||
|
||||
function hasCloudflared() {
|
||||
const probe = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
|
||||
return !probe.error;
|
||||
}
|
||||
|
||||
if (!hasCloudflared()) {
|
||||
console.error(
|
||||
"\n✖ cloudflared is not installed.\n" +
|
||||
" Install it, then re-run `npm run dev:tunnel`:\n\n" +
|
||||
" brew install cloudflared # macOS\n" +
|
||||
" # or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let backend = null;
|
||||
let resolvedUrl = null;
|
||||
const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
||||
|
||||
console.log(`\n⛅ Starting Cloudflare tunnel to http://localhost:${PORT} …\n`);
|
||||
|
||||
const tunnel = spawn(
|
||||
"cloudflared",
|
||||
// --protocol http2 keeps the edge connection on TCP/443 (QUIC/UDP is blocked
|
||||
// on many networks and would leave the tunnel unable to connect).
|
||||
["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${PORT}`],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
function onTunnelOutput(buf) {
|
||||
const text = buf.toString();
|
||||
process.stderr.write(text); // keep cloudflared's own logs visible
|
||||
if (resolvedUrl) return;
|
||||
const match = text.match(TUNNEL_RE);
|
||||
if (match) {
|
||||
resolvedUrl = match[0];
|
||||
startBackend(resolvedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
tunnel.stdout.on("data", onTunnelOutput);
|
||||
tunnel.stderr.on("data", onTunnelOutput);
|
||||
|
||||
function startBackend(publicUrl) {
|
||||
console.log(
|
||||
`\n✅ Public relay URL: ${publicUrl}\n` +
|
||||
" Generate a QR in the web app (Patients → Import from a patient app → QR);\n" +
|
||||
" it will carry this URL, so a phone on any network can scan to connect.\n",
|
||||
);
|
||||
backend = spawn("npm", ["run", "dev"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PUBLIC_RELAY_URL: publicUrl },
|
||||
});
|
||||
backend.on("exit", (code) => shutdown(code ?? 0));
|
||||
}
|
||||
|
||||
function shutdown(code) {
|
||||
for (const proc of [backend, tunnel]) {
|
||||
if (proc && !proc.killed) proc.kill("SIGTERM");
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
tunnel.on("exit", (code) => {
|
||||
if (!resolvedUrl) {
|
||||
console.error("\n✖ cloudflared exited before a tunnel URL was established.");
|
||||
}
|
||||
shutdown(code ?? 0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
+32
-5
@@ -9,6 +9,7 @@ import * as authSchema from "./db/schema/auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { ac, roles } from "./lib/access.js";
|
||||
import { sendEmail } from "./lib/email.js";
|
||||
import { isAllowedOrigin } from "./lib/origins.js";
|
||||
|
||||
const WEEK = 60 * 60 * 24 * 7;
|
||||
const DAY = 60 * 60 * 24;
|
||||
@@ -17,7 +18,18 @@ export const auth = betterAuth({
|
||||
appName: "temetro",
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
trustedOrigins: [env.FRONTEND_URL],
|
||||
// Trust the configured frontend origin plus localhost/LAN hosts so staff can
|
||||
// sign in over the network (mirrors CORS; see src/lib/origins.ts). Reflecting
|
||||
// the request's own origin (when allowed) keeps Better Auth's CSRF check happy
|
||||
// without a per-deployment rebuild.
|
||||
trustedOrigins: (request) => {
|
||||
const origins = [env.FRONTEND_URL];
|
||||
const origin = request?.headers.get("origin");
|
||||
if (origin && isAllowedOrigin(origin) && !origins.includes(origin)) {
|
||||
origins.push(origin);
|
||||
}
|
||||
return origins;
|
||||
},
|
||||
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
@@ -35,10 +47,25 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 256,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your temetro password",
|
||||
text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
|
||||
// With a provider configured, email the reset link. Otherwise fall back to
|
||||
// alerting the clinic admin(s) so they can set a new password (dynamic
|
||||
// imports keep the Better Auth CLI's static graph minimal at generate time).
|
||||
const { isEmailConfigured } = await import("./services/email-config.js");
|
||||
if (await isEmailConfigured()) {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your temetro password",
|
||||
text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { notifyAdminsPasswordReset } = await import(
|
||||
"./services/auth-fallback.js"
|
||||
);
|
||||
await notifyAdminsPasswordReset({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// Deployment-wide email-provider configuration — a single row (id = "default").
|
||||
// Email (verification, password reset, invitations) is sent while the user is
|
||||
// logged out / before any clinic exists, so this can't be per-clinic; it's one
|
||||
// config for the whole deployment, set by any clinic admin from Settings →
|
||||
// Developers. The provider's API key is stored encrypted (lib/crypto.ts).
|
||||
export const emailSettings = pgTable("email_settings", {
|
||||
id: text("id").primaryKey().default("default"),
|
||||
// "none" | "smtp" | "resend" | "postmark" | "sendgrid"
|
||||
provider: text("provider").notNull().default("none"),
|
||||
fromAddress: text("from_address").notNull().default(""),
|
||||
// Encrypted API key (Resend/Postmark/SendGrid). SMTP uses env credentials.
|
||||
credentials: text("credentials"),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
@@ -11,8 +11,13 @@ export * from "./activity.js";
|
||||
export * from "./messaging.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./settings.js";
|
||||
export * from "./email-settings.js";
|
||||
export * from "./ai.js";
|
||||
export * from "./ai-chat.js";
|
||||
export * from "./org-ai-policy.js";
|
||||
export * from "./attachments.js";
|
||||
export * from "./integrations.js";
|
||||
export * from "./staff-profile.js";
|
||||
export * from "./meetings.js";
|
||||
export * from "./signing.js";
|
||||
export * from "./wallet-share.js";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// A persistent staff meeting room (Discord-style voice/video channel), scoped to
|
||||
// a clinic. Rooms are long-lived "channels"; the live call (participants, media)
|
||||
// is ephemeral and lives only in the realtime layer — nothing about an in-call
|
||||
// session is persisted here.
|
||||
export const meetingRooms = pgTable(
|
||||
"meeting_rooms",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("meeting_rooms_org_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
// A scheduled staff meeting (calendar event), scoped to a clinic. `participants`
|
||||
// holds the invited staff user ids; `date`/`time` are local strings (YYYY-MM-DD /
|
||||
// HH:mm) like appointments, to avoid timezone drift on the calendar.
|
||||
export const scheduledMeetings = pgTable(
|
||||
"scheduled_meetings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
date: text("date").notNull(),
|
||||
time: text("time").notNull(),
|
||||
participants: jsonb("participants").$type<string[]>().notNull().default([]),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("scheduled_meetings_org_idx").on(table.organizationId)],
|
||||
);
|
||||
@@ -54,6 +54,11 @@ export const patients = pgTable(
|
||||
// with auto-generated file numbers or placeholder fields) and are flagged
|
||||
// for clinician review/edit.
|
||||
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
|
||||
// Provenance for records imported from a patient wallet app, plus the
|
||||
// auto-delete deadline for a *temporary* share. When `shareExpiresAt` is set
|
||||
// and passes, a scheduled sweep hard-deletes the row (services/wallet-share).
|
||||
shareOrigin: text("share_origin").$type<"wallet">(),
|
||||
shareExpiresAt: timestamp("share_expires_at"),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
// One Ed25519 signing key per clinic (organization). The clinician's edits to a
|
||||
// patient record are signed with this key so patients (and other clinics) can
|
||||
// verify a change really came from this clinic before approving it. The private
|
||||
// key is stored encrypted at rest (lib/crypto.ts `encryptSecret`); the public
|
||||
// key + fingerprint are shown in Settings → Signing. Rotating replaces the row.
|
||||
export const clinicSigningKeys = pgTable("clinic_signing_keys", {
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
algorithm: text("algorithm").notNull().default("ed25519"),
|
||||
publicKey: text("public_key").notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
||||
privateKeyEnc: text("private_key_enc").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
rotatedAt: timestamp("rotated_at"),
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// Per-member, per-clinic profile extras that Better Auth's member row doesn't
|
||||
// hold. Currently just a doctor's clinical specialty (e.g. "Orthopedist",
|
||||
// "Dentist") which the admin sets in Care Team and surfaces on the patient
|
||||
// sheet for the patient's primary provider. Unique on (org, user) so each
|
||||
// member has at most one profile per clinic.
|
||||
export const staffProfile = pgTable(
|
||||
"staff_profile",
|
||||
{
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
specialty: text("specialty"),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("staff_profile_org_user_idx").on(
|
||||
table.organizationId,
|
||||
table.userId,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -25,6 +25,11 @@ export const tasks = pgTable(
|
||||
// The department (member role) a task is assigned to, e.g. "reception".
|
||||
// Null means a personal task belonging to its creator. Drives who sees it.
|
||||
assigneeRole: text("assignee_role"),
|
||||
// A specific person the task is assigned to. Null when assigned to a
|
||||
// department or kept personal. The assignee display name lives in `assignee`.
|
||||
assigneeUserId: text("assignee_user_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
due: text("due").notNull().default("No due date"),
|
||||
priority: text("priority").$type<TaskPriority>().notNull(),
|
||||
// Board column: todo | in_progress | done. Kept in sync with `done`
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
import type { Patient } from "../../types/patient.js";
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
export type WalletShareStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "denied"
|
||||
| "expired";
|
||||
export type WalletShareMode = "permanent" | "temporary";
|
||||
|
||||
// One row per "import from a patient app" request. A clinician enters a wallet
|
||||
// number; we mint a per-request ephemeral X25519 keypair (the phone seals the
|
||||
// record bundle to its public key) and relay a `share:request` to the device
|
||||
// over the /wallet Socket.io namespace. The patient approves on their phone; the
|
||||
// sealed bundle comes back, we decrypt it with `ephemeralPrivEnc` and hand the
|
||||
// clinic a draft Patient to review. Nothing here is the record itself — only the
|
||||
// transient handshake + the decrypted draft cached until the clinic commits it.
|
||||
export const walletShareRequests = pgTable(
|
||||
"wallet_share_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
requestedBy: text("requested_by")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// Null for QR "scan to connect" pairing requests — the wallet number is
|
||||
// bound when the authenticated device responds. Set up-front for the
|
||||
// type-the-number push flow.
|
||||
walletNumber: text("wallet_number"),
|
||||
ephemeralPubKey: text("ephemeral_pub_key").notNull(),
|
||||
// Encrypted (lib/crypto.ts) hex of the ephemeral X25519 private key.
|
||||
ephemeralPrivEnc: text("ephemeral_priv_enc").notNull(),
|
||||
status: text("status").$type<WalletShareStatus>().notNull().default("pending"),
|
||||
shareMode: text("share_mode").$type<WalletShareMode>().notNull().default("permanent"),
|
||||
// For temporary shares: when the imported record should be auto-deleted.
|
||||
shareExpiresAt: timestamp("share_expires_at"),
|
||||
// The decrypted, verified draft record, cached between approval and commit.
|
||||
draft: jsonb("draft").$type<Patient | null>(),
|
||||
// Set once the clinic commits the draft — the imported patient's file number,
|
||||
// so a later patient "revoke" from the app can delete exactly that record.
|
||||
committedFileNumber: text("committed_file_number"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
resolvedAt: timestamp("resolved_at"),
|
||||
},
|
||||
(t) => [
|
||||
index("wallet_share_org_idx").on(t.organizationId),
|
||||
index("wallet_share_wallet_idx").on(t.walletNumber),
|
||||
],
|
||||
);
|
||||
@@ -21,6 +21,21 @@ const schema = z.object({
|
||||
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
||||
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
||||
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
|
||||
// Extra browser origins allowed to call the API with credentials, beyond
|
||||
// FRONTEND_URL and the auto-allowed private/LAN hosts (see src/lib/origins.ts).
|
||||
// Comma-separated; set to "*" to allow any origin (only for trusted networks).
|
||||
TRUSTED_ORIGINS: z.string().optional(),
|
||||
// Overrides the version reported by GET /api/version. Normally derived from
|
||||
// package.json; the release pipeline can pin it explicitly.
|
||||
APP_VERSION: z.string().optional(),
|
||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||
// QR a patient scans. Optional — when unset we derive it from the request host
|
||||
// (so opening the web app over the LAN yields a reachable LAN URL).
|
||||
PUBLIC_RELAY_URL: z.string().optional(),
|
||||
// Metrics URL of a cloudflared quick-tunnel sidecar (e.g. http://cloudflared:3333).
|
||||
// When set and PUBLIC_RELAY_URL is unset, the backend discovers its public
|
||||
// trycloudflare.com URL from there for Dockerized off-network testing.
|
||||
CLOUDFLARED_METRICS_URL: z.string().optional(),
|
||||
PORT: z.coerce.number().int().positive().default(4000),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
|
||||
+44
-1
@@ -6,9 +6,11 @@ import express from "express";
|
||||
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { isAllowedOrigin } from "./lib/origins.js";
|
||||
import { errorHandler, notFound } from "./middleware/error.js";
|
||||
import { initRealtime } from "./realtime.js";
|
||||
import { activityRouter } from "./routes/activity.js";
|
||||
import { authHelpersRouter } from "./routes/auth-helpers.js";
|
||||
import { aiRouter } from "./routes/ai.js";
|
||||
import { analyticsRouter } from "./routes/analytics.js";
|
||||
import { attachmentsRouter } from "./routes/attachments.js";
|
||||
@@ -19,22 +21,34 @@ import { dispensesRouter } from "./routes/dispenses.js";
|
||||
import { integrationsRouter } from "./routes/integrations.js";
|
||||
import { inventoryRouter } from "./routes/inventory.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
import { meetingsRouter } from "./routes/meetings.js";
|
||||
import { notesRouter } from "./routes/notes.js";
|
||||
import { notificationsRouter } from "./routes/notifications.js";
|
||||
import { patientsRouter } from "./routes/patients.js";
|
||||
import { patientsWalletRouter } from "./routes/patients-wallet.js";
|
||||
import { portalRouter } from "./routes/portal.js";
|
||||
import { prescriptionsRouter } from "./routes/prescriptions.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { signingRouter } from "./routes/signing.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { networkRouter } from "./routes/network.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { versionRouter } from "./routes/version.js";
|
||||
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
||||
import { sweepExpiredShares } from "./services/wallet-share.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// Behind docker / a reverse proxy we trust forwarding headers for client IPs.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// Allow the configured frontend origin plus localhost/LAN hosts, so other
|
||||
// departments can reach the app over the network (see src/lib/origins.ts).
|
||||
// Requests without an Origin header (curl, same-origin server calls) pass too.
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.FRONTEND_URL,
|
||||
origin: (origin, cb) =>
|
||||
cb(null, !origin || isAllowedOrigin(origin)),
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
@@ -64,7 +78,15 @@ app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Public, unauthenticated: running version + update check, and LAN access info.
|
||||
app.use("/api/version", versionRouter);
|
||||
app.use("/api/network", networkRouter);
|
||||
|
||||
// Mount the wallet import routes BEFORE the generic patients router so
|
||||
// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`.
|
||||
app.use("/api/patients/wallet", patientsWalletRouter);
|
||||
app.use("/api/patients", patientsRouter);
|
||||
app.use("/api/signing", signingRouter);
|
||||
app.use("/api/attachments", attachmentsRouter);
|
||||
app.use("/api/notes", notesRouter);
|
||||
app.use("/api/appointments", appointmentsRouter);
|
||||
@@ -77,11 +99,14 @@ app.use("/api/staff", staffRouter);
|
||||
app.use("/api/activity", activityRouter);
|
||||
app.use("/api/analytics", analyticsRouter);
|
||||
app.use("/api/conversations", conversationsRouter);
|
||||
app.use("/api/meetings", meetingsRouter);
|
||||
app.use("/api/notifications", notificationsRouter);
|
||||
app.use("/api/settings", settingsRouter);
|
||||
app.use("/api/ai", aiRouter);
|
||||
app.use("/api/chat", chatRouter);
|
||||
app.use("/api/integrations", integrationsRouter);
|
||||
app.use("/api/portal", portalRouter);
|
||||
app.use("/api/auth-helpers", authHelpersRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
@@ -90,6 +115,14 @@ app.use(errorHandler);
|
||||
const server = createServer(app);
|
||||
initRealtime(server);
|
||||
|
||||
// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes.
|
||||
const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
sweepExpiredShares().catch((err) =>
|
||||
console.error("Wallet share sweep failed:", err),
|
||||
);
|
||||
}, SHARE_SWEEP_INTERVAL).unref();
|
||||
|
||||
server.listen(env.PORT, () => {
|
||||
console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`);
|
||||
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
|
||||
@@ -110,4 +143,14 @@ server.listen(env.PORT, () => {
|
||||
console.log(` • ai: /api/ai (config + import)`);
|
||||
console.log(` • chat: /api/chat (LLM agent)`);
|
||||
console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`);
|
||||
console.log(` • portal: /api/portal (public clinic kiosk)`);
|
||||
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
|
||||
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
|
||||
});
|
||||
|
||||
// Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL
|
||||
// (from the `tunnel` compose profile) so the wallet-import QR points at it.
|
||||
// No-op unless a tunnel sidecar is configured and PUBLIC_RELAY_URL is unset.
|
||||
if (env.CLOUDFLARED_METRICS_URL && !env.PUBLIC_RELAY_URL) {
|
||||
void beginQuickTunnelDiscovery(env.CLOUDFLARED_METRICS_URL);
|
||||
}
|
||||
|
||||
+126
-26
@@ -1,6 +1,7 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { env } from "../env.js";
|
||||
import { getActiveConfig } from "../services/email-config.js";
|
||||
|
||||
type SendArgs = {
|
||||
to: string;
|
||||
@@ -9,10 +10,9 @@ type SendArgs = {
|
||||
html?: string;
|
||||
};
|
||||
|
||||
// Lazily build a transport. With SMTP_HOST configured we send real mail;
|
||||
// otherwise we fall back to logging the message (and any links) to the
|
||||
// server console — zero setup for local / open-source development.
|
||||
const transport = env.SMTP_HOST
|
||||
// SMTP transport (built from env) — used when the active provider is "smtp" or,
|
||||
// for backward compatibility, when SMTP_HOST is set and no provider is chosen.
|
||||
const smtpTransport = env.SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT ?? 587,
|
||||
@@ -24,26 +24,126 @@ const transport = env.SMTP_HOST
|
||||
})
|
||||
: null;
|
||||
|
||||
export async function sendEmail({ to, subject, text, html }: SendArgs): Promise<void> {
|
||||
if (!transport) {
|
||||
console.info(
|
||||
[
|
||||
"",
|
||||
"✉️ [email:console] No SMTP configured — printing instead of sending.",
|
||||
` to: ${to}`,
|
||||
` subject: ${subject}`,
|
||||
` body: ${text}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html: html ?? text,
|
||||
});
|
||||
function logToConsole({ to, subject, text }: SendArgs): void {
|
||||
console.info(
|
||||
[
|
||||
"",
|
||||
"✉️ [email:console] No email provider configured — printing instead of sending.",
|
||||
` to: ${to}`,
|
||||
` subject: ${subject}`,
|
||||
` body: ${text}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendViaResend(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ from, to, subject, text, html: html ?? text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resend failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function sendViaPostmark(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.postmarkapp.com/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Postmark-Server-Token": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
TextBody: text,
|
||||
HtmlBody: html ?? text,
|
||||
MessageStream: "outbound",
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Postmark failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function sendViaSendgrid(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
{ to, subject, text, html }: SendArgs,
|
||||
): Promise<void> {
|
||||
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: { email: from },
|
||||
subject,
|
||||
content: [
|
||||
{ type: "text/plain", value: text },
|
||||
{ type: "text/html", value: html ?? text },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`SendGrid failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Send an email via the deployment's configured provider. Falls back to logging
|
||||
// when nothing is configured so local/open-source dev needs zero setup.
|
||||
export async function sendEmail(args: SendArgs): Promise<void> {
|
||||
const cfg = await getActiveConfig();
|
||||
// A real "from": the configured address, else the SMTP default.
|
||||
const from = cfg.fromAddress || env.SMTP_FROM;
|
||||
|
||||
switch (cfg.provider) {
|
||||
case "resend":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaResend(cfg.credentials, from, args);
|
||||
case "postmark":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaPostmark(cfg.credentials, from, args);
|
||||
case "sendgrid":
|
||||
if (!cfg.credentials) return logToConsole(args);
|
||||
return sendViaSendgrid(cfg.credentials, from, args);
|
||||
case "smtp": {
|
||||
if (!smtpTransport) return logToConsole(args);
|
||||
await smtpTransport.sendMail({
|
||||
from,
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html ?? args.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
// No provider chosen — honour a pre-existing SMTP env setup if present.
|
||||
if (smtpTransport) {
|
||||
await smtpTransport.sendMail({
|
||||
from,
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html ?? args.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return logToConsole(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Decides which browser origins may call the API with credentials.
|
||||
//
|
||||
// temetro is self-hosted: a clinic runs it on one machine and other departments
|
||||
// reach it over the LAN by the host's IP (e.g. http://192.168.1.20:3000). The
|
||||
// browser there sends that LAN origin, which must be allowed for both CORS and
|
||||
// Better Auth's CSRF/trusted-origin check. We allow:
|
||||
// - FRONTEND_URL (the configured origin),
|
||||
// - any explicitly listed TRUSTED_ORIGINS (or "*" for any),
|
||||
// - localhost and private/LAN hosts (so LAN access works with no config).
|
||||
import { env } from "../env.js";
|
||||
|
||||
const configured = (env.TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowAll = configured.includes("*");
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return true;
|
||||
}
|
||||
// Private IPv4 ranges (RFC 1918) + link-local.
|
||||
if (/^10\./.test(hostname)) return true;
|
||||
if (/^192\.168\./.test(hostname)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true;
|
||||
if (/^169\.254\./.test(hostname)) return true;
|
||||
// mDNS .local hostnames (e.g. clinic-pc.local).
|
||||
if (hostname.endsWith(".local")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if `origin` (an `Origin` header value) is allowed to call the API. */
|
||||
export function isAllowedOrigin(origin: string | undefined | null): boolean {
|
||||
if (!origin) return false;
|
||||
if (allowAll) return true;
|
||||
if (origin === env.FRONTEND_URL) return true;
|
||||
if (configured.includes(origin)) return true;
|
||||
try {
|
||||
return isPrivateHost(new URL(origin).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const taskInputSchema = z.object({
|
||||
title: z.string().trim().min(1, "A task subject is required.").max(200),
|
||||
assignee: z.string().trim().max(200).default("Unassigned"),
|
||||
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
||||
assigneeUserId: z.string().trim().max(120).nullish(),
|
||||
due: z.string().trim().max(120).default("No due date"),
|
||||
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
||||
status: z.enum(["todo", "in_progress", "done"]).default("todo"),
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import {
|
||||
bytesToHex,
|
||||
concatBytes,
|
||||
hexToBytes,
|
||||
randomBytes,
|
||||
} from "@noble/hashes/utils.js";
|
||||
|
||||
// Cryptographic primitives shared (by convention — the wire format is mirrored
|
||||
// in the mobile wallet app's src/lib/crypto.ts) between the clinic backend and
|
||||
// the patient wallet. Identity is an Ed25519 keypair; the patient's public key,
|
||||
// base58check-encoded with a `tmw_` prefix, is their human-typeable **wallet
|
||||
// number**. Record bundles are sealed to a recipient's ephemeral X25519 key
|
||||
// (sealed-box: ephemeral sender key + X25519 ECDH + XChaCha20-Poly1305) so the
|
||||
// relay only ever forwards ciphertext, and signed with the wallet's Ed25519 key
|
||||
// so the recipient can verify the bundle truly came from that wallet number.
|
||||
//
|
||||
// Both apps use @noble so the byte layout is identical on every platform.
|
||||
|
||||
export const WALLET_PREFIX = "tmw_";
|
||||
|
||||
const B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
function base58Encode(bytes: Uint8Array): string {
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
const digits: number[] = [];
|
||||
for (let i = zeros; i < bytes.length; i++) {
|
||||
let carry = bytes[i] as number;
|
||||
for (let j = 0; j < digits.length; j++) {
|
||||
carry += (digits[j] as number) << 8;
|
||||
digits[j] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
let out = "1".repeat(zeros);
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
out += B58_ALPHABET[digits[i] as number];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function base58Decode(str: string): Uint8Array {
|
||||
let zeros = 0;
|
||||
while (zeros < str.length && str[zeros] === "1") zeros++;
|
||||
const bytes: number[] = [];
|
||||
for (let i = zeros; i < str.length; i++) {
|
||||
const value = B58_ALPHABET.indexOf(str[i] as string);
|
||||
if (value < 0) throw new Error("Invalid base58 character.");
|
||||
let carry = value;
|
||||
for (let j = 0; j < bytes.length; j++) {
|
||||
carry += (bytes[j] as number) * 58;
|
||||
bytes[j] = carry & 0xff;
|
||||
carry >>= 8;
|
||||
}
|
||||
while (carry > 0) {
|
||||
bytes.push(carry & 0xff);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(zeros + bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out[zeros + bytes.length - 1 - i] = bytes[i] as number;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checksum(payload: Uint8Array): Uint8Array {
|
||||
return sha256(sha256(payload)).slice(0, 4);
|
||||
}
|
||||
|
||||
// --- Ed25519 identity -------------------------------------------------------
|
||||
|
||||
export function newSigningKeypair(): { privateKeyHex: string; publicKeyHex: string } {
|
||||
const privateKey = ed25519.utils.randomSecretKey();
|
||||
const publicKey = ed25519.getPublicKey(privateKey);
|
||||
return {
|
||||
privateKeyHex: bytesToHex(privateKey),
|
||||
publicKeyHex: bytesToHex(publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function signMessage(privateKeyHex: string, message: Uint8Array): string {
|
||||
return bytesToHex(ed25519.sign(message, hexToBytes(privateKeyHex)));
|
||||
}
|
||||
|
||||
export function verifySignature(
|
||||
publicKey: Uint8Array,
|
||||
signatureHex: string,
|
||||
message: Uint8Array,
|
||||
): boolean {
|
||||
try {
|
||||
return ed25519.verify(hexToBytes(signatureHex), message, publicKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// `ed25519:9f86 d081 …` — a short, human-comparable fingerprint of a public key
|
||||
// (first 16 bytes of its SHA-256, grouped in fours). Matches the panel format.
|
||||
export function fingerprint(publicKey: Uint8Array): string {
|
||||
const hex = bytesToHex(sha256(publicKey)).slice(0, 32);
|
||||
const groups = hex.match(/.{1,4}/g) ?? [];
|
||||
return `ed25519:${groups.join(" ")}`;
|
||||
}
|
||||
|
||||
// --- Wallet number (base58check of the Ed25519 public key) ------------------
|
||||
|
||||
export function encodeWalletNumber(publicKey: Uint8Array): string {
|
||||
const payload = concatBytes(publicKey, checksum(publicKey));
|
||||
return WALLET_PREFIX + base58Encode(payload);
|
||||
}
|
||||
|
||||
// Decode + validate a wallet number back to its 32-byte Ed25519 public key.
|
||||
// Throws on a bad prefix, bad base58, wrong length, or checksum mismatch.
|
||||
export function decodeWalletNumber(walletNumber: string): Uint8Array {
|
||||
const trimmed = walletNumber.trim();
|
||||
if (!trimmed.startsWith(WALLET_PREFIX)) {
|
||||
throw new Error("Wallet number must start with tmw_.");
|
||||
}
|
||||
const decoded = base58Decode(trimmed.slice(WALLET_PREFIX.length));
|
||||
if (decoded.length !== 36) {
|
||||
throw new Error("Wallet number has an invalid length.");
|
||||
}
|
||||
const publicKey = decoded.slice(0, 32);
|
||||
const check = decoded.slice(32);
|
||||
const expected = checksum(publicKey);
|
||||
if (check.some((b, i) => b !== expected[i])) {
|
||||
throw new Error("Wallet number checksum mismatch (likely a typo).");
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export function isValidWalletNumber(walletNumber: string): boolean {
|
||||
try {
|
||||
decodeWalletNumber(walletNumber);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sealed box (anonymous sender -> recipient X25519 public key) -----------
|
||||
|
||||
export function newEncryptionKeypair(): {
|
||||
privateKeyHex: string;
|
||||
publicKeyHex: string;
|
||||
} {
|
||||
const privateKey = x25519.utils.randomSecretKey();
|
||||
const publicKey = x25519.getPublicKey(privateKey);
|
||||
return {
|
||||
privateKeyHex: bytesToHex(privateKey),
|
||||
publicKeyHex: bytesToHex(publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveKey(
|
||||
shared: Uint8Array,
|
||||
ephemeralPub: Uint8Array,
|
||||
recipientPub: Uint8Array,
|
||||
): Uint8Array {
|
||||
return sha256(concatBytes(shared, ephemeralPub, recipientPub));
|
||||
}
|
||||
|
||||
// Seal `plaintext` to `recipientPublicKeyHex` (X25519). Returns base64 of
|
||||
// `ephemeralPub(32) || nonce(24) || ciphertext`.
|
||||
export function seal(
|
||||
recipientPublicKeyHex: string,
|
||||
plaintext: Uint8Array,
|
||||
): string {
|
||||
const recipientPub = hexToBytes(recipientPublicKeyHex);
|
||||
const ephemeralPriv = x25519.utils.randomSecretKey();
|
||||
const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
|
||||
const shared = x25519.getSharedSecret(ephemeralPriv, recipientPub);
|
||||
const key = deriveKey(shared, ephemeralPub, recipientPub);
|
||||
const nonce = randomBytes(24);
|
||||
const ciphertext = xchacha20poly1305(key, nonce).encrypt(plaintext);
|
||||
return Buffer.from(concatBytes(ephemeralPub, nonce, ciphertext)).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
export function open(
|
||||
recipientPrivateKeyHex: string,
|
||||
sealedBase64: string,
|
||||
): Uint8Array {
|
||||
const recipientPriv = hexToBytes(recipientPrivateKeyHex);
|
||||
const recipientPub = x25519.getPublicKey(recipientPriv);
|
||||
const blob = new Uint8Array(Buffer.from(sealedBase64, "base64"));
|
||||
const ephemeralPub = blob.slice(0, 32);
|
||||
const nonce = blob.slice(32, 56);
|
||||
const ciphertext = blob.slice(56);
|
||||
const shared = x25519.getSharedSecret(recipientPriv, ephemeralPub);
|
||||
const key = deriveKey(shared, ephemeralPub, recipientPub);
|
||||
return xchacha20poly1305(key, nonce).decrypt(ciphertext);
|
||||
}
|
||||
+252
-1
@@ -1,18 +1,34 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { Server, type Socket } from "socket.io";
|
||||
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
|
||||
import * as meetings from "./services/meetings.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import * as walletShare from "./services/wallet-share.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
|
||||
const userRoom = (userId: string) => `user:${userId}`;
|
||||
const convRoom = (conversationId: string) => `conv:${conversationId}`;
|
||||
const callRoom = (roomId: string) => `call:${roomId}`;
|
||||
const orgRoom = (orgId: string) => `org:${orgId}`;
|
||||
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
|
||||
|
||||
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
|
||||
// past that the room is closed to new joiners.
|
||||
const MAX_CALL_PEERS = 4;
|
||||
|
||||
// Live call participants per room: roomId -> (socketId -> peer info). Ephemeral —
|
||||
// nothing about an in-call session is persisted.
|
||||
type CallPeer = { socketId: string; userId: string; userName: string };
|
||||
const callParticipants = new Map<string, Map<string, CallPeer>>();
|
||||
|
||||
// Push helpers other modules can call without importing socket.io directly.
|
||||
export function emitToUser(userId: string, event: string, data: unknown): void {
|
||||
@@ -27,6 +43,17 @@ export function emitToConversation(
|
||||
io?.to(convRoom(conversationId)).emit(event, data);
|
||||
}
|
||||
|
||||
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
|
||||
// namespace, room keyed by wallet number). The relay only ever forwards
|
||||
// ciphertext — it cannot read the record bundle.
|
||||
export function emitToWallet(
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
|
||||
}
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
export function initRealtime(httpServer: HttpServer): Server {
|
||||
@@ -58,8 +85,9 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
const userName: string = socket.data.userName;
|
||||
const orgId: string | null = socket.data.orgId;
|
||||
|
||||
// Personal room for notifications.
|
||||
// Personal room for notifications; clinic room for call presence broadcasts.
|
||||
socket.join(userRoom(userId));
|
||||
if (orgId) socket.join(orgRoom(orgId));
|
||||
|
||||
socket.on(
|
||||
"conversation:join",
|
||||
@@ -133,6 +161,229 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
await messaging.markRead(orgId, userId, conversationId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Staff calls (WebRTC mesh signaling) -------------------------------
|
||||
// The server only relays SDP/ICE between peers and tracks who's in a room;
|
||||
// media flows peer-to-peer and never touches the server. Rooms are
|
||||
// org-scoped: a join is authorized against the clinic's meeting_rooms.
|
||||
const joinedCallRooms = new Set<string>();
|
||||
|
||||
// Broadcast a room's live occupancy to the whole clinic so the meetings
|
||||
// room list can show "N in call".
|
||||
const emitPresence = (roomId: string) => {
|
||||
if (!orgId) return;
|
||||
const count = callParticipants.get(roomId)?.size ?? 0;
|
||||
io?.to(orgRoom(orgId)).emit("call:presence", { roomId, count });
|
||||
};
|
||||
|
||||
const leaveCall = (roomId: string) => {
|
||||
if (!joinedCallRooms.has(roomId)) return;
|
||||
joinedCallRooms.delete(roomId);
|
||||
socket.leave(callRoom(roomId));
|
||||
callParticipants.get(roomId)?.delete(socket.id);
|
||||
if (callParticipants.get(roomId)?.size === 0) {
|
||||
callParticipants.delete(roomId);
|
||||
}
|
||||
socket.to(callRoom(roomId)).emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
};
|
||||
|
||||
socket.on("call:join", async (roomId: unknown, ack?: Ack) => {
|
||||
try {
|
||||
const id = String(roomId ?? "");
|
||||
if (!(id && orgId && (await meetings.roomExists(orgId, id)))) {
|
||||
ack?.({ ok: false, reason: "not_found" });
|
||||
return;
|
||||
}
|
||||
const peers = callParticipants.get(id) ?? new Map<string, CallPeer>();
|
||||
if (!peers.has(socket.id) && peers.size >= MAX_CALL_PEERS) {
|
||||
ack?.({ ok: false, reason: "full" });
|
||||
return;
|
||||
}
|
||||
socket.join(callRoom(id));
|
||||
joinedCallRooms.add(id);
|
||||
const me: CallPeer = { socketId: socket.id, userId, userName };
|
||||
peers.set(socket.id, me);
|
||||
callParticipants.set(id, peers);
|
||||
// Tell existing peers a newcomer arrived; the newcomer initiates offers.
|
||||
socket.to(callRoom(id)).emit("call:peer-joined", me);
|
||||
emitPresence(id);
|
||||
// Reply with the peers already present (excluding self).
|
||||
ack?.({
|
||||
ok: true,
|
||||
peers: [...peers.values()].filter((p) => p.socketId !== socket.id),
|
||||
});
|
||||
} catch {
|
||||
ack?.({ ok: false, reason: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Ring a clinic member into a room: push a live invite + a bell notification.
|
||||
socket.on(
|
||||
"call:invite",
|
||||
async (payload: { roomId?: string; toUserId?: string }) => {
|
||||
try {
|
||||
const roomId = String(payload?.roomId ?? "");
|
||||
const toUserId = String(payload?.toUserId ?? "");
|
||||
if (!(roomId && toUserId && orgId)) return;
|
||||
if (!(await meetings.roomExists(orgId, roomId))) return;
|
||||
const room = (await meetings.listRooms(orgId)).find(
|
||||
(r) => r.id === roomId,
|
||||
);
|
||||
const roomName = room?.name ?? "";
|
||||
emitToUser(toUserId, "call:invite", {
|
||||
roomId,
|
||||
roomName,
|
||||
fromName: userName,
|
||||
});
|
||||
const notification = await createNotification({
|
||||
orgId,
|
||||
userId: toUserId,
|
||||
type: "meeting",
|
||||
text: `${userName} invited you to a call`,
|
||||
entityType: "meeting",
|
||||
entityId: roomId,
|
||||
actorName: userName,
|
||||
});
|
||||
if (notification) {
|
||||
emitToUser(toUserId, "notification:new", notification);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Relay an SDP offer/answer or ICE candidate to a specific peer socket.
|
||||
socket.on(
|
||||
"call:signal",
|
||||
(payload: { to?: string; signal?: unknown }) => {
|
||||
const to = String(payload?.to ?? "");
|
||||
if (!to) return;
|
||||
io?.to(to).emit("call:signal", {
|
||||
from: socket.id,
|
||||
signal: payload.signal,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("call:leave", (roomId: unknown) => {
|
||||
leaveCall(String(roomId ?? ""));
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
for (const roomId of joinedCallRooms) {
|
||||
callParticipants.get(roomId)?.delete(socket.id);
|
||||
if (callParticipants.get(roomId)?.size === 0) {
|
||||
callParticipants.delete(roomId);
|
||||
}
|
||||
socket
|
||||
.to(callRoom(roomId))
|
||||
.emit("call:peer-left", { socketId: socket.id });
|
||||
emitPresence(roomId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Patient wallet relay (/wallet namespace) ----------------------------
|
||||
// Devices have no clinic session, so this namespace is NOT cookie-gated.
|
||||
// Instead a device proves control of its wallet keypair: the server issues a
|
||||
// random challenge, the device signs it with its Ed25519 key, and only then
|
||||
// may it join its own wallet room. The relay forwards encrypted share
|
||||
// requests/responses without ever reading the record bundle.
|
||||
const walletNs = io.of("/wallet");
|
||||
walletNs.on("connection", (socket: Socket) => {
|
||||
const challenge = bytesToHex(randomBytes(32));
|
||||
socket.data.challenge = challenge;
|
||||
socket.data.walletNumber = null as string | null;
|
||||
socket.emit("wallet:challenge", { challenge });
|
||||
|
||||
socket.on(
|
||||
"wallet:auth",
|
||||
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
|
||||
try {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
const signature = String(payload?.signature ?? "");
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
const ok = verifySignature(
|
||||
publicKey,
|
||||
signature,
|
||||
utf8ToBytes(socket.data.challenge as string),
|
||||
);
|
||||
if (!ok) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
socket.data.walletNumber = walletNumber;
|
||||
socket.join(walletRoom(walletNumber));
|
||||
ack?.({ ok: true });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share on their device; the sealed bundle (if
|
||||
// approved) rides along and is decrypted + verified server-side.
|
||||
socket.on(
|
||||
"wallet:share-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
sealed?: string;
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletShare.applyShareResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.sealed,
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient revoked a previously shared record; delete it from the clinic.
|
||||
socket.on(
|
||||
"wallet:revoke",
|
||||
async (
|
||||
payload: { requestId?: string; walletNumber?: string },
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const result = await walletShare.revokeShare(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
);
|
||||
ack?.({ ok: !!result });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return io;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { user } from "../db/schema/auth.js";
|
||||
|
||||
// Public auth helpers that sit alongside Better Auth's own /api/auth handler.
|
||||
//
|
||||
// Better Auth's password-reset endpoint is keyed by email, but staff
|
||||
// provisioned by an admin sign in with a *username* (and may only have a
|
||||
// synthetic `username@slug.temetro.local` address). This lets them start a reset
|
||||
// by username: we resolve the username to its account, then hand off to the
|
||||
// normal reset flow (which emails a link if a provider is configured, or alerts
|
||||
// the clinic admins otherwise — see src/auth.ts sendResetPassword).
|
||||
export const authHelpersRouter = Router();
|
||||
|
||||
const resetByUsernameSchema = z.object({
|
||||
username: z.string().trim().min(1).max(64),
|
||||
redirectTo: z.string().trim().max(2048).optional(),
|
||||
});
|
||||
|
||||
// POST /api/auth-helpers/reset-by-username
|
||||
// Always responds 200 with a generic body — never reveals whether the username
|
||||
// exists (avoids account enumeration) and never echoes the resolved email.
|
||||
authHelpersRouter.post("/reset-by-username", async (req, res, next) => {
|
||||
try {
|
||||
const { username, redirectTo } = resetByUsernameSchema.parse(req.body);
|
||||
|
||||
const [account] = await db
|
||||
.select({ email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.username, username.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (account?.email) {
|
||||
// Reuse Better Auth's reset flow so the same dispatch/fallback logic runs.
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: account.email, redirectTo },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -90,7 +90,7 @@ function modeDirective(mode: string | undefined): string {
|
||||
return "Mode — Analysis: the clinician wants interpretation, not just retrieval. After fetching a patient's data, surface patterns and correlations across their problems, labs and visits (e.g. recurring complaints, trends, likely links) and call out anything notable. Stay grounded in the tool results.";
|
||||
}
|
||||
if (mode === "graph") {
|
||||
return "Mode — Graph: the clinician wants to see how a patient's problems and visits connect. When they reference a patient, call getPatient (its record graph renders automatically) and briefly describe the key relationships between illnesses and encounters.";
|
||||
return "Mode — Graph: the clinician wants to see how a patient's problems and visits connect. When they reference a patient, call getPatient — in this mode it renders the patient's record GRAPH (not cards) automatically — then briefly describe the key relationships between illnesses and encounters. Do not say you cannot draw a graph; getPatient produces it.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -163,6 +163,14 @@ function systemPrompt(
|
||||
"instructions. Never invent clinical values; only state what the tools return.",
|
||||
"The record cards are rendered to the clinician automatically when you call a",
|
||||
"tool, so keep your prose a brief summary rather than re-listing every field.",
|
||||
"",
|
||||
"Citations: every retrieval tool result includes a `sourceId` (e.g. \"s1\").",
|
||||
"Cite **sparingly** — add at most ONE marker per paragraph, on the single most",
|
||||
"important record-derived claim, in the exact form [[src:ID]] using the matching",
|
||||
"sourceId (e.g. \"BP is well controlled this quarter [[src:s1]].\"). Do NOT cite",
|
||||
"every sentence, do NOT cite individual list items (e.g. each allergy or",
|
||||
"medication), and never repeat the same source more than once. Cite only facts",
|
||||
"grounded in tool results, and never invent or guess a sourceId.",
|
||||
veilActive
|
||||
? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.`
|
||||
: "",
|
||||
@@ -222,7 +230,7 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const tools = createChatTools({ ...ctx, veil, writer });
|
||||
const tools = createChatTools({ ...ctx, mode, veil, writer });
|
||||
|
||||
if (resolved.isExternal && veil.active) {
|
||||
// Non-streamed pass so we can rehydrate identifier tokens before the
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||
import * as meetings from "../services/meetings.js";
|
||||
|
||||
export const meetingsRouter = Router();
|
||||
|
||||
// Staff meeting rooms (Discord-style voice/video channels), scoped to the active
|
||||
// clinic. Any clinic member can list, create, and join rooms — calls are
|
||||
// staff-to-staff. The live call (media + participants) is handled over Socket.io
|
||||
// (see src/realtime.ts); these endpoints only manage the persistent room list.
|
||||
meetingsRouter.use(requireAuth, requireOrg);
|
||||
|
||||
meetingsRouter.get("/", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await meetings.listRooms(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
});
|
||||
|
||||
meetingsRouter.post("/", async (req, res, next) => {
|
||||
try {
|
||||
const { name } = createSchema.parse(req.body);
|
||||
const room = await meetings.createRoom(
|
||||
req.organizationId!,
|
||||
name,
|
||||
req.user!.id,
|
||||
);
|
||||
res.status(201).json(room);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
meetingsRouter.get("/events", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await meetings.listMeetingEvents(req.organizationId!, req.user!.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const eventSchema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
participants: z.array(z.string()).max(50).default([]),
|
||||
});
|
||||
|
||||
meetingsRouter.post("/events", async (req, res, next) => {
|
||||
try {
|
||||
const input = eventSchema.parse(req.body);
|
||||
const event = await meetings.createMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
);
|
||||
res.status(201).json(event);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/events/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteMeetingEvent(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
String(req.params.id ?? ""),
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Meeting not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
meetingsRouter.delete("/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await meetings.deleteRoom(
|
||||
req.organizationId!,
|
||||
String(req.params.id ?? ""),
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Room not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// GET /api/network — best-effort discovery of LAN addresses other departments
|
||||
// can use to reach temetro, for the Settings "Network access" panel.
|
||||
//
|
||||
// Caveat: inside Docker's default bridge network this sees the container's IPs,
|
||||
// not the host's LAN IP, so the frontend prefers the address the browser is
|
||||
// actually using (window.location) and treats this as a fallback/hint.
|
||||
import { networkInterfaces } from "node:os";
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import { env } from "../env.js";
|
||||
|
||||
function frontendPort(): number {
|
||||
try {
|
||||
const port = new URL(env.FRONTEND_URL).port;
|
||||
return port ? Number(port) : 3000;
|
||||
} catch {
|
||||
return 3000;
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const port = frontendPort();
|
||||
const addresses: string[] = [];
|
||||
for (const iface of Object.values(networkInterfaces())) {
|
||||
for (const net of iface ?? []) {
|
||||
// Node <18 reports family as "IPv4"; >=18 may report the number 4.
|
||||
const isV4 = net.family === "IPv4" || (net.family as unknown) === 4;
|
||||
if (isV4 && !net.internal) addresses.push(net.address);
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
port,
|
||||
addresses,
|
||||
urls: addresses.map((ip) => `http://${ip}:${port}`),
|
||||
});
|
||||
});
|
||||
|
||||
export const networkRouter = router;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { organization } from "../db/schema/auth.js";
|
||||
import { env } from "../env.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { patientInputSchema } from "../lib/patient-validation.js";
|
||||
import { isReceptionOnly } from "../lib/role-scope.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { emitToWallet } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as patientService from "../services/patients.js";
|
||||
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
|
||||
export const patientsWalletRouter = Router();
|
||||
|
||||
patientsWalletRouter.use(requireAuth, requireOrg);
|
||||
|
||||
// The device-reachable URL the patient's app should connect to (baked into the
|
||||
// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request
|
||||
// host so that opening the web app over the LAN yields a reachable LAN URL.
|
||||
async function resolveRelayUrl(req: Request): Promise<string> {
|
||||
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
||||
// A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to
|
||||
// become reachable so the QR never carries a not-yet-live URL.
|
||||
if (env.CLOUDFLARED_METRICS_URL) {
|
||||
const tunnel = await awaitQuickTunnelUrl(env.CLOUDFLARED_METRICS_URL);
|
||||
if (tunnel) return tunnel;
|
||||
}
|
||||
const host = req.get("host");
|
||||
if (host) {
|
||||
// Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http";
|
||||
// trust x-forwarded-proto so the QR carries an https URL — the phone then
|
||||
// connects over wss, which iOS App Transport Security requires.
|
||||
const proto =
|
||||
req.get("x-forwarded-proto")?.split(",")[0]?.trim() || req.protocol;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
return env.BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
const requestSchema = z.object({
|
||||
walletNumber: z.string().trim().min(1),
|
||||
mode: z.enum(["permanent", "temporary"]).default("permanent"),
|
||||
durationHours: z.number().positive().max(8760).optional(),
|
||||
});
|
||||
|
||||
const pairSchema = z.object({
|
||||
mode: z.enum(["permanent", "temporary"]).default("permanent"),
|
||||
durationHours: z.number().positive().max(8760).optional(),
|
||||
});
|
||||
|
||||
// Create a QR pairing request (no wallet number yet). Returns the request id +
|
||||
// the ephemeral public key the device seals its bundle to; the clinic encodes
|
||||
// both — plus its own relay URL — into the QR the patient scans.
|
||||
patientsWalletRouter.post(
|
||||
"/pair",
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = pairSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input.mode,
|
||||
input.durationHours,
|
||||
);
|
||||
res.status(201).json({
|
||||
...view,
|
||||
ephemeralPubKey,
|
||||
relayUrl: await resolveRelayUrl(req),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Start an import: validate the wallet number, mint a per-request ephemeral key,
|
||||
// and relay an encrypted-share request to the patient's device. The clinician
|
||||
// then polls the request until the patient approves on their phone.
|
||||
patientsWalletRouter.post(
|
||||
"/request-share",
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = requestSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input.walletNumber,
|
||||
input.mode,
|
||||
input.durationHours,
|
||||
);
|
||||
const [org] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, req.organizationId!));
|
||||
emitToWallet(input.walletNumber, "wallet:share-request", {
|
||||
requestId: view.id,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
requestedBy: req.user!.name,
|
||||
ephemeralPubKey,
|
||||
mode: input.mode,
|
||||
durationHours: input.durationHours ?? null,
|
||||
});
|
||||
res.status(201).json(view);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Poll a request's status (and, once approved, the decrypted draft record).
|
||||
patientsWalletRouter.get(
|
||||
"/request-share/:id",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const view = await walletShare.getShareRequest(
|
||||
req.organizationId!,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!view) throw new HttpError(404, "Share request not found.");
|
||||
res.json(view);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Commit the (possibly clinician-edited) draft into a real patient record. The
|
||||
// temporary-share metadata (origin + auto-delete deadline) is taken from the
|
||||
// request server-side, so the clinic can't quietly keep a temporary record.
|
||||
patientsWalletRouter.post(
|
||||
"/request-share/:id/commit",
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const request = await walletShare.getShareRequest(req.organizationId!, id);
|
||||
if (!request) throw new HttpError(404, "Share request not found.");
|
||||
if (request.status !== "approved") {
|
||||
throw new HttpError(409, "This share has not been approved yet.");
|
||||
}
|
||||
const input = patientInputSchema.parse(req.body);
|
||||
const created = await patientService.createPatient(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
isReceptionOnly(req.memberRole),
|
||||
{
|
||||
shareOrigin: "wallet",
|
||||
shareExpiresAt: request.shareExpiresAt
|
||||
? new Date(request.shareExpiresAt)
|
||||
: null,
|
||||
},
|
||||
);
|
||||
await walletShare.markCommitted(
|
||||
req.organizationId!,
|
||||
id,
|
||||
created.fileNumber,
|
||||
);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Imported patient ${created.name} from a wallet${
|
||||
request.shareMode === "temporary" ? " (temporary)" : ""
|
||||
}`,
|
||||
entityType: "patient",
|
||||
entityId: created.fileNumber,
|
||||
patientName: created.name,
|
||||
patientFileNumber: created.fileNumber,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Router, type Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { organization } from "../db/schema/auth.js";
|
||||
import { appointmentInputSchema } from "../lib/appointment-validation.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { initialsFromName } from "../lib/initials.js";
|
||||
import { patientInputSchema } from "../lib/patient-validation.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { createAppointment, listAppointments } from "../services/appointments.js";
|
||||
import { createPatient, getPatient } from "../services/patients.js";
|
||||
|
||||
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
|
||||
// waiting room). Scoped by the clinic slug in the URL — there is no session.
|
||||
//
|
||||
// PHI exposure is deliberately minimal: lookups require BOTH a file number and a
|
||||
// matching name, and "results" return only appointment status + whether results
|
||||
// exist, never lab values. A kiosk token / one-time code would be the safer
|
||||
// long-term design (see docs).
|
||||
export const portalRouter = Router();
|
||||
|
||||
async function resolveClinic(req: Request): Promise<{ id: string; name: string }> {
|
||||
const slug = String(req.params.clinic ?? "").trim();
|
||||
if (!slug) throw new HttpError(404, "Clinic not found.");
|
||||
const [org] = await db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.slug, slug))
|
||||
.limit(1);
|
||||
if (!org) throw new HttpError(404, "Clinic not found.");
|
||||
return org;
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase();
|
||||
|
||||
// GET /api/portal/:clinic — clinic name for the kiosk header.
|
||||
portalRouter.get("/:clinic", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
res.json({ name: clinic.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const bookingSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1, "A file number is required.").max(64),
|
||||
name: z.string().trim().min(1, "Your name is required.").max(200),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
|
||||
type: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
const newPatientSchema = z.object({
|
||||
name: z.string().trim().min(1, "Your name is required.").max(200),
|
||||
sex: z.string().trim().optional(),
|
||||
age: z.coerce.number().int().min(0).max(150).optional(),
|
||||
});
|
||||
|
||||
// POST /api/portal/:clinic/patients — register a new (demographics-only) patient
|
||||
// from the kiosk so a first-time visitor can get a file number and then book.
|
||||
// Writes only demographics (no clinical PHI) from this unauthenticated surface.
|
||||
portalRouter.post("/:clinic/patients", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const body = newPatientSchema.parse(req.body);
|
||||
const input = patientInputSchema.parse({
|
||||
name: body.name,
|
||||
sex: body.sex ?? "M",
|
||||
age: body.age ?? 0,
|
||||
source: "manual",
|
||||
});
|
||||
const created = await createPatient(clinic.id, "", input, true);
|
||||
await recordActivity({
|
||||
orgId: clinic.id,
|
||||
actor: { id: "", name: created.name },
|
||||
action: `Patient portal registration — ${created.name}`,
|
||||
entityType: "patient",
|
||||
entityId: created.fileNumber,
|
||||
});
|
||||
res.status(201).json({ fileNumber: created.fileNumber, name: created.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/portal/:clinic/appointments — self-service booking for a registered
|
||||
// patient. Verifies the file number + name, then creates a confirmed appointment
|
||||
// that shows up on the clinic's Appointments page.
|
||||
portalRouter.post("/:clinic/appointments", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const body = bookingSchema.parse(req.body);
|
||||
|
||||
const patient = await getPatient(clinic.id, body.fileNumber);
|
||||
if (!patient || norm(patient.name) !== norm(body.name)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"We couldn't find a record matching that name and file number.",
|
||||
);
|
||||
}
|
||||
// Don't allow booking in the past.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (body.date < today) {
|
||||
throw new HttpError(400, "Please pick a future date.");
|
||||
}
|
||||
|
||||
const input = appointmentInputSchema.parse({
|
||||
fileNumber: patient.fileNumber,
|
||||
name: patient.name,
|
||||
initials: patient.initials || initialsFromName(patient.name),
|
||||
date: body.date,
|
||||
time: body.time,
|
||||
type: body.type || "Self-service booking",
|
||||
provider: patient.pcp || "",
|
||||
status: "confirmed",
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
// Prevent double-booking the same slot: a provider can't have two
|
||||
// appointments at the same date+time (clinic-wide when the provider is
|
||||
// unknown). Cancelled appointments don't count.
|
||||
const taken = (await listAppointments(clinic.id)).some(
|
||||
(a) =>
|
||||
a.status !== "cancelled" &&
|
||||
a.date === input.date &&
|
||||
a.time === input.time &&
|
||||
(!input.provider || !a.provider || a.provider === input.provider),
|
||||
);
|
||||
if (taken) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"That time slot is already taken. Please choose another time.",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createAppointment(clinic.id, "", input);
|
||||
await recordActivity({
|
||||
orgId: clinic.id,
|
||||
actor: { id: "", name: patient.name },
|
||||
action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`,
|
||||
entityType: "appointment",
|
||||
entityId: created.id,
|
||||
});
|
||||
res.status(201).json({
|
||||
date: created.date,
|
||||
time: created.time,
|
||||
type: created.type,
|
||||
provider: created.provider,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const lookupSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
});
|
||||
|
||||
// GET /api/portal/:clinic/results?fileNumber=&name= — minimal status view.
|
||||
// Returns upcoming appointments and whether results are on file, never the
|
||||
// underlying clinical values.
|
||||
portalRouter.get("/:clinic/results", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const q = lookupSchema.parse({
|
||||
fileNumber: req.query.fileNumber,
|
||||
name: req.query.name,
|
||||
});
|
||||
const patient = await getPatient(clinic.id, q.fileNumber);
|
||||
if (!patient || norm(patient.name) !== norm(q.name)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"We couldn't find a record matching that name and file number.",
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const upcoming = (await listAppointments(clinic.id))
|
||||
.filter(
|
||||
(a) =>
|
||||
a.fileNumber === patient.fileNumber &&
|
||||
a.status !== "cancelled" &&
|
||||
new Date(`${a.date}T${a.time}`) >= now,
|
||||
)
|
||||
.map((a) => ({
|
||||
date: a.date,
|
||||
time: a.time,
|
||||
type: a.type,
|
||||
provider: a.provider,
|
||||
status: a.status,
|
||||
}));
|
||||
res.json({
|
||||
name: patient.name,
|
||||
upcoming,
|
||||
hasResults: patient.labs.length > 0,
|
||||
resultCount: patient.labs.length,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,24 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { userSettings } from "../db/schema/settings.js";
|
||||
import { patientInputSchema } from "../lib/patient-validation.js";
|
||||
import { settingsInputSchema } from "../lib/settings-validation.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import {
|
||||
type EmailProvider,
|
||||
getPublicConfig,
|
||||
saveConfig,
|
||||
} from "../services/email-config.js";
|
||||
import { createPatient, listPatients } from "../services/patients.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -12,6 +26,175 @@ export const settingsRouter = Router();
|
||||
// no active organization or RBAC permission.
|
||||
settingsRouter.use(requireAuth);
|
||||
|
||||
// --- Email provider (deployment-wide, admin-only) ------------------------
|
||||
// One config for the whole deployment (email is sent while logged out, so it
|
||||
// can't be per-clinic). Gated by `member: ["create"]` — any clinic admin sets
|
||||
// the deployment's provider. The API key is never returned.
|
||||
|
||||
const emailConfigSchema = z.object({
|
||||
provider: z.enum(["none", "smtp", "resend", "postmark", "sendgrid"]),
|
||||
fromAddress: z.string().trim().max(200).default(""),
|
||||
// undefined = leave key as-is; "" = clear; string = set/replace.
|
||||
credentials: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
settingsRouter.get(
|
||||
"/email",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await getPublicConfig());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
settingsRouter.put(
|
||||
"/email",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = emailConfigSchema.parse(req.body);
|
||||
const saved = await saveConfig({
|
||||
provider: input.provider as EmailProvider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials: input.credentials,
|
||||
});
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Updated email provider — ${saved.provider}`,
|
||||
entityType: "settings",
|
||||
entityId: "email",
|
||||
});
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
settingsRouter.post(
|
||||
"/email/test",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: req.user!.email,
|
||||
subject: "temetro email test",
|
||||
text: `This is a test email from temetro. If you received it, your email provider is configured correctly.`,
|
||||
});
|
||||
res.json({ ok: true, to: req.user!.email });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Records import / export (clinic-wide, admin-only) -------------------
|
||||
// Gated by `member: ["create"]` — the same admin/owner marker the staff route
|
||||
// uses — so only clinic admins can bulk-move records.
|
||||
|
||||
// Download every patient record in the active clinic as one JSON archive.
|
||||
settingsRouter.get(
|
||||
"/records/export",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const patients = await listPatients(req.organizationId!);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Exported ${patients.length} patient record(s)`,
|
||||
entityType: "patient",
|
||||
entityId: "export",
|
||||
});
|
||||
res.json({
|
||||
temetroExport: true,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
organizationId: req.organizationId,
|
||||
patientCount: patients.length,
|
||||
patients,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Import a previously exported archive. Creates new patients and skips any whose
|
||||
// file number already exists in this clinic (idempotent re-imports). Cross-clinic
|
||||
// provider links are dropped — they reference users this clinic doesn't have.
|
||||
const importBodySchema = z.object({
|
||||
patients: z.array(z.unknown()).max(10_000),
|
||||
});
|
||||
|
||||
settingsRouter.post(
|
||||
"/records/import",
|
||||
requireOrg,
|
||||
requirePermission({ member: ["create"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { patients: incoming } = importBodySchema.parse(req.body);
|
||||
const existing = new Set(
|
||||
(await listPatients(req.organizationId!)).map((p) => p.fileNumber),
|
||||
);
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const raw of incoming) {
|
||||
// Provider links are clinic-specific; drop them so the FK holds.
|
||||
const candidate =
|
||||
raw && typeof raw === "object"
|
||||
? { ...(raw as Record<string, unknown>), primaryProviderId: null }
|
||||
: raw;
|
||||
const parsed = patientInputSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
if (errors.length < 20) {
|
||||
const name =
|
||||
(candidate as { name?: string })?.name ?? "(unknown)";
|
||||
errors.push(`${name}: ${parsed.error.issues[0]?.message ?? "invalid"}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parsed.data.fileNumber && existing.has(parsed.data.fileNumber)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const made = await createPatient(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
parsed.data,
|
||||
);
|
||||
existing.add(made.fileNumber);
|
||||
created += 1;
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Imported ${created} patient record(s)`,
|
||||
entityType: "patient",
|
||||
entityId: "import",
|
||||
});
|
||||
res.json({ created, skipped, total: incoming.length, errors });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
settingsRouter.get("/", async (req, res, next) => {
|
||||
try {
|
||||
const rows = await db
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as signing from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
|
||||
export const signingRouter = Router();
|
||||
|
||||
signingRouter.use(requireAuth, requireOrg);
|
||||
|
||||
// The clinic's Ed25519 signing key (public key + fingerprint). Created lazily on
|
||||
// first read so the panel always shows a real key. Readable by any clinician.
|
||||
signingRouter.get(
|
||||
"/key",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await signing.getOrCreateKey(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Rotate the signing key — owner/admin only (gated on the org-update statement,
|
||||
// which only owner/admin hold).
|
||||
signingRouter.post(
|
||||
"/key/rotate",
|
||||
requirePermission({ organization: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const key = await signing.rotateKey(req.organizationId!);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: "Rotated the clinic signing key",
|
||||
entityType: "settings",
|
||||
});
|
||||
res.json(key);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recent records shared from patient wallets — feeds the panel's shared-records
|
||||
// list.
|
||||
signingRouter.get(
|
||||
"/records",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await walletShare.listShareRequests(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { member, organization, user } from "../db/schema/auth.js";
|
||||
import { staffProfile } from "../db/schema/staff-profile.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { requireAuth, requireOrg, requirePermission } from "../middleware/auth.js";
|
||||
|
||||
@@ -65,9 +66,17 @@ staffRouter.get("/providers", async (req, res, next) => {
|
||||
userId: member.userId,
|
||||
name: user.name,
|
||||
role: member.role,
|
||||
specialty: staffProfile.specialty,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(user.id, member.userId))
|
||||
.leftJoin(
|
||||
staffProfile,
|
||||
and(
|
||||
eq(staffProfile.userId, member.userId),
|
||||
eq(staffProfile.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, req.organizationId!),
|
||||
@@ -96,9 +105,17 @@ staffRouter.get(
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
specialty: staffProfile.specialty,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(user.id, member.userId))
|
||||
.leftJoin(
|
||||
staffProfile,
|
||||
and(
|
||||
eq(staffProfile.userId, member.userId),
|
||||
eq(staffProfile.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
.where(eq(member.organizationId, req.organizationId!))
|
||||
.orderBy(asc(user.name));
|
||||
res.json(rows);
|
||||
@@ -168,3 +185,87 @@ staffRouter.post(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update a member's clinical specialty. Empty string clears it. Owner/admin
|
||||
// only. Upserts the per-clinic staff_profile row.
|
||||
const specialtyInputSchema = z.object({
|
||||
specialty: z.preprocess(
|
||||
(v) => (typeof v === "string" && v.trim() === "" ? null : v),
|
||||
z.string().trim().max(60).nullable(),
|
||||
),
|
||||
});
|
||||
|
||||
staffRouter.patch(
|
||||
"/:userId",
|
||||
requirePermission({ member: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.params.userId ?? "");
|
||||
const { specialty } = specialtyInputSchema.parse(req.body);
|
||||
const organizationId = req.organizationId!;
|
||||
|
||||
// The target must be a member of this clinic.
|
||||
const [target] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, organizationId),
|
||||
eq(member.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!target) throw new HttpError(404, "Member not found.");
|
||||
|
||||
await db
|
||||
.insert(staffProfile)
|
||||
.values({ organizationId, userId, specialty })
|
||||
.onConflictDoUpdate({
|
||||
target: [staffProfile.organizationId, staffProfile.userId],
|
||||
set: { specialty },
|
||||
});
|
||||
|
||||
res.json({ userId, specialty });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Set a member's password directly (admin-driven reset — e.g. the employee
|
||||
// forgot it and no email provider is configured). Owner/admin only, and the
|
||||
// target must be a member of this clinic. Uses Better Auth's internal context to
|
||||
// hash + store the password (the same calls its admin plugin makes), so no admin
|
||||
// plugin is required.
|
||||
const passwordInputSchema = z.object({
|
||||
newPassword: z.string().min(12).max(256),
|
||||
});
|
||||
|
||||
staffRouter.patch(
|
||||
"/:userId/password",
|
||||
requirePermission({ member: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.params.userId ?? "");
|
||||
const { newPassword } = passwordInputSchema.parse(req.body);
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, req.organizationId!),
|
||||
eq(member.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!target) throw new HttpError(404, "Member not found.");
|
||||
|
||||
const ctx = await auth.$context;
|
||||
const hashed = await ctx.password.hash(newPassword);
|
||||
await ctx.internalAdapter.updatePassword(userId, hashed);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// GET /api/version — reports the running version and whether a newer release
|
||||
// exists on GitHub. Public (no PHI); the frontend uses it for the Settings
|
||||
// "About & Updates" panel and the optional update banner.
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import { env } from "../env.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// package.json is the source of truth for the running version (copied into the
|
||||
// runtime image). APP_VERSION can override it (set by the release pipeline).
|
||||
const pkg = require("../../package.json") as { version?: string };
|
||||
const CURRENT = env.APP_VERSION ?? pkg.version ?? "0.0.0";
|
||||
|
||||
const LATEST_RELEASE_URL =
|
||||
"https://api.github.com/repos/temetro/temetro/releases/latest";
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6h — releases are infrequent.
|
||||
const ERROR_TTL = 10 * 60 * 1000; // back off ~10m after a failed lookup.
|
||||
|
||||
type LatestInfo = { latest: string | null; releaseUrl: string | null };
|
||||
let cache: { at: number; ttl: number; info: LatestInfo } | null = null;
|
||||
|
||||
function parseSemver(v: string): [number, number, number] | null {
|
||||
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
|
||||
/** True if `latest` is a strictly newer semver than `current`. */
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const a = parseSemver(latest);
|
||||
const b = parseSemver(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i]! > b[i]!) return true;
|
||||
if (a[i]! < b[i]!) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchLatest(): Promise<LatestInfo> {
|
||||
if (cache && Date.now() - cache.at < cache.ttl) return cache.info;
|
||||
try {
|
||||
const res = await fetch(LATEST_RELEASE_URL, {
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
|
||||
const body = (await res.json()) as { tag_name?: string; html_url?: string };
|
||||
const info: LatestInfo = {
|
||||
latest: body.tag_name ? body.tag_name.replace(/^v/, "") : null,
|
||||
releaseUrl: body.html_url ?? null,
|
||||
};
|
||||
cache = { at: Date.now(), ttl: CACHE_TTL, info };
|
||||
return info;
|
||||
} catch {
|
||||
// Fail soft: keep any prior value, briefly cache the miss to avoid hammering.
|
||||
const info: LatestInfo = cache?.info ?? { latest: null, releaseUrl: null };
|
||||
cache = { at: Date.now(), ttl: ERROR_TTL, info };
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
const { latest, releaseUrl } = await fetchLatest();
|
||||
res.json({
|
||||
current: CURRENT,
|
||||
latest,
|
||||
updateAvailable: latest ? isNewer(latest, CURRENT) : false,
|
||||
releaseUrl,
|
||||
});
|
||||
});
|
||||
|
||||
export const versionRouter = router;
|
||||
@@ -33,6 +33,9 @@ export type ToolContext = {
|
||||
// The signed-in clinician — needed to scope task visibility (and to stamp the
|
||||
// creator when an add is committed via the REST endpoints on approval).
|
||||
viewer: { userId: string; userName: string; memberRole: string };
|
||||
// The composer's "situation" mode (chat | analysis | graph). In graph mode
|
||||
// getPatient renders the record graph instead of the record cards.
|
||||
mode?: string;
|
||||
veil: Veil;
|
||||
writer: UIMessageStreamWriter;
|
||||
};
|
||||
@@ -57,7 +60,7 @@ function forModel(p: Patient) {
|
||||
}
|
||||
|
||||
export function createChatTools(ctx: ToolContext) {
|
||||
const { orgId, demographicsOnly, scopeProviderId, viewer, veil, writer } =
|
||||
const { orgId, demographicsOnly, scopeProviderId, viewer, mode, veil, writer } =
|
||||
ctx;
|
||||
|
||||
// Emit a Chain-of-Thought step to the UI as the agent works. Steps stream live
|
||||
@@ -73,6 +76,18 @@ export function createChatTools(ctx: ToolContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// Register a citable source the model can reference inline. The title is the
|
||||
// REAL, clinician-facing label (streamed to the trusted UI, like the cards);
|
||||
// the returned id is PHI-free (`s1`, `s2`, …) so it survives Veil rehydration
|
||||
// when the model echoes it back as a [[src:id]] marker.
|
||||
let sourceSeq = 0;
|
||||
function addSource(title: string, kind: string): string {
|
||||
sourceSeq += 1;
|
||||
const id = `s${sourceSeq}`;
|
||||
writer.write({ type: "data-source", data: { id, title, kind } });
|
||||
return id;
|
||||
}
|
||||
|
||||
// Resolve a possibly-tokenized file number to the real patient record, so an
|
||||
// add proposal carries real name/initials into the approval card (the model
|
||||
// only ever saw Veil tokens). Returns null when the patient isn't found / is
|
||||
@@ -86,7 +101,7 @@ export function createChatTools(ctx: ToolContext) {
|
||||
// Look up one patient by file number (MRN) and show their record cards.
|
||||
getPatient: tool({
|
||||
description:
|
||||
"Retrieve a patient's full record by file number (MRN) and display it as record cards. Use when the clinician asks about a specific patient.",
|
||||
"Retrieve a patient's full record by file number (MRN). Displays it as record cards (or, in Graph mode, as the patient's record graph). Use when the clinician asks about a specific patient.",
|
||||
inputSchema: z.object({
|
||||
fileNumber: z
|
||||
.string()
|
||||
@@ -102,9 +117,22 @@ export function createChatTools(ctx: ToolContext) {
|
||||
scopeProviderId,
|
||||
);
|
||||
if (!patient) return { found: false as const, fileNumber };
|
||||
// Real data → clinician UI (cards). Redacted data → model.
|
||||
writer.write({ type: "data-patientCard", data: patient });
|
||||
return { found: true as const, patient: forModel(veil.redactPatient(patient)) };
|
||||
// Real data → clinician UI. In graph mode render the record graph; in
|
||||
// chat/analysis modes render the record cards. Redacted data → model.
|
||||
writer.write(
|
||||
mode === "graph"
|
||||
? { type: "data-recordGraph", data: patient }
|
||||
: { type: "data-patientCard", data: patient },
|
||||
);
|
||||
const sourceId = addSource(
|
||||
`${patient.name} · MRN ${patient.fileNumber}`,
|
||||
"patient",
|
||||
);
|
||||
return {
|
||||
found: true as const,
|
||||
sourceId,
|
||||
patient: forModel(veil.redactPatient(patient)),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -138,8 +166,10 @@ export function createChatTools(ctx: ToolContext) {
|
||||
},
|
||||
});
|
||||
const redacted = veil.redactPatient(patient);
|
||||
const sourceId = addSource(`Labs · ${patient.name}`, "lab");
|
||||
return {
|
||||
found: true as const,
|
||||
sourceId,
|
||||
name: redacted.name,
|
||||
labs: patient.labs,
|
||||
labTrend: patient.labTrend,
|
||||
@@ -197,7 +227,8 @@ export function createChatTools(ctx: ToolContext) {
|
||||
status: a.status,
|
||||
patient: veil.active ? "[PATIENT]" : a.name,
|
||||
}));
|
||||
return { count: rows.length, appointments: rows };
|
||||
const sourceId = addSource("Appointments schedule", "appointments");
|
||||
return { count: rows.length, sourceId, appointments: rows };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -219,7 +250,8 @@ export function createChatTools(ctx: ToolContext) {
|
||||
priority: tk.priority,
|
||||
done: tk.done,
|
||||
}));
|
||||
return { count: rows.length, tasks: rows };
|
||||
const sourceId = addSource("Task list", "tasks");
|
||||
return { count: rows.length, sourceId, tasks: rows };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -245,7 +277,8 @@ export function createChatTools(ctx: ToolContext) {
|
||||
prescribedAt: rx.prescribedAt,
|
||||
patient: veil.active ? "[PATIENT]" : rx.name,
|
||||
}));
|
||||
return { count: rows.length, prescriptions: rows };
|
||||
const sourceId = addSource("Prescriptions", "prescriptions");
|
||||
return { count: rows.length, sourceId, prescriptions: rows };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -438,7 +471,8 @@ export function createChatTools(ctx: ToolContext) {
|
||||
createdAt: org?.createdAt ? org.createdAt.toISOString() : null,
|
||||
};
|
||||
writer.write({ type: "data-clinicCard", data: info });
|
||||
return info;
|
||||
const sourceId = addSource(`Clinic · ${info.name}`, "clinic");
|
||||
return { ...info, sourceId };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -450,7 +484,8 @@ export function createChatTools(ctx: ToolContext) {
|
||||
step("Loading clinic analytics");
|
||||
const data = await analytics.getAnalytics(orgId);
|
||||
writer.write({ type: "data-analyticsCard", data });
|
||||
return data; // aggregates only, no PHI
|
||||
const sourceId = addSource("Clinic analytics", "analytics");
|
||||
return { ...data, sourceId }; // aggregates only, no PHI
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -462,8 +497,10 @@ export function createChatTools(ctx: ToolContext) {
|
||||
step("Loading inventory");
|
||||
const items = await inventory.listInventory(orgId);
|
||||
writer.write({ type: "data-inventoryList", data: { items } });
|
||||
const sourceId = addSource("Inventory", "inventory");
|
||||
return {
|
||||
count: items.length,
|
||||
sourceId,
|
||||
items: items.map((i) => ({
|
||||
name: i.name,
|
||||
form: i.form,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { member } from "../db/schema/auth.js";
|
||||
import { emitToUser } from "../realtime.js";
|
||||
import { createSystemMessage } from "./messaging.js";
|
||||
import { createNotification } from "./notifications.js";
|
||||
|
||||
// When an employee asks for a password reset but no email provider is configured,
|
||||
// alert the admin(s) of each clinic the user belongs to: a "System" message card
|
||||
// in Messages + a bell notification, both deep-linking to that member's settings
|
||||
// so an admin can set a new password. The reset URL is never exposed.
|
||||
export async function notifyAdminsPasswordReset(u: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}): Promise<void> {
|
||||
const orgs = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, u.id));
|
||||
const orgIds = [...new Set(orgs.map((o) => o.organizationId))];
|
||||
|
||||
for (const orgId of orgIds) {
|
||||
try {
|
||||
const admins = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgId),
|
||||
inArray(member.role, ["owner", "admin"]),
|
||||
),
|
||||
);
|
||||
const adminIds = admins.map((a) => a.userId).filter((id) => id !== u.id);
|
||||
if (adminIds.length === 0) continue;
|
||||
|
||||
const body = `${u.name} requested a password reset, but no email provider is configured. Reset their password from their settings.`;
|
||||
const { message, recipientIds } = await createSystemMessage(
|
||||
orgId,
|
||||
adminIds,
|
||||
body,
|
||||
{
|
||||
kind: "passwordReset",
|
||||
userId: u.id,
|
||||
userName: u.name,
|
||||
userEmail: u.email,
|
||||
},
|
||||
);
|
||||
for (const rid of recipientIds) emitToUser(rid, "message:new", message);
|
||||
|
||||
for (const rid of adminIds) {
|
||||
const n = await createNotification({
|
||||
orgId,
|
||||
userId: rid,
|
||||
type: "password_reset",
|
||||
text: `${u.name} needs a password reset`,
|
||||
entityType: "user",
|
||||
entityId: u.id,
|
||||
actorName: u.name,
|
||||
});
|
||||
if (n) emitToUser(rid, "notification:new", n);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("password-reset fallback failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { emailSettings } from "../db/schema/email-settings.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
|
||||
export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid";
|
||||
|
||||
const ROW_ID = "default";
|
||||
|
||||
// Providers that authenticate with an API key (so the UI knows to ask for one).
|
||||
const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"];
|
||||
|
||||
export type PublicEmailConfig = {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
hasCredentials: boolean;
|
||||
};
|
||||
|
||||
export type ActiveEmailConfig = {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
credentials: string | null;
|
||||
};
|
||||
|
||||
async function getRow() {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(emailSettings)
|
||||
.where(eq(emailSettings.id, ROW_ID))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getPublicConfig(): Promise<PublicEmailConfig> {
|
||||
const row = await getRow();
|
||||
return {
|
||||
provider: (row?.provider as EmailProvider) ?? "none",
|
||||
fromAddress: row?.fromAddress ?? "",
|
||||
hasCredentials: Boolean(row?.credentials),
|
||||
};
|
||||
}
|
||||
|
||||
// Internal — includes the decrypted API key. Used by lib/email.ts at send time.
|
||||
export async function getActiveConfig(): Promise<ActiveEmailConfig> {
|
||||
const row = await getRow();
|
||||
return {
|
||||
provider: (row?.provider as EmailProvider) ?? "none",
|
||||
fromAddress: row?.fromAddress ?? "",
|
||||
credentials: row?.credentials ? decryptSecret(row.credentials) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// True when the deployment can actually deliver email (a real provider is set,
|
||||
// and API-key providers have a key). SMTP relies on env, treated as configured.
|
||||
export async function isEmailConfigured(): Promise<boolean> {
|
||||
const cfg = await getActiveConfig();
|
||||
if (cfg.provider === "none") return false;
|
||||
if (API_KEY_PROVIDERS.includes(cfg.provider)) return Boolean(cfg.credentials);
|
||||
return true; // smtp
|
||||
}
|
||||
|
||||
export async function saveConfig(input: {
|
||||
provider: EmailProvider;
|
||||
fromAddress: string;
|
||||
// undefined = leave existing key untouched; "" = clear it.
|
||||
credentials?: string;
|
||||
}): Promise<PublicEmailConfig> {
|
||||
const existing = await getRow();
|
||||
const credentials =
|
||||
input.credentials === undefined
|
||||
? (existing?.credentials ?? null)
|
||||
: input.credentials
|
||||
? encryptSecret(input.credentials)
|
||||
: null;
|
||||
|
||||
await db
|
||||
.insert(emailSettings)
|
||||
.values({
|
||||
id: ROW_ID,
|
||||
provider: input.provider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: emailSettings.id,
|
||||
set: {
|
||||
provider: input.provider,
|
||||
fromAddress: input.fromAddress,
|
||||
credentials,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return getPublicConfig();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { user } from "../db/schema/auth.js";
|
||||
import { meetingRooms, scheduledMeetings } from "../db/schema/meetings.js";
|
||||
|
||||
export type MeetingRoom = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function listRooms(orgId: string): Promise<MeetingRoom[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: meetingRooms.id,
|
||||
name: meetingRooms.name,
|
||||
createdAt: meetingRooms.createdAt,
|
||||
})
|
||||
.from(meetingRooms)
|
||||
.where(eq(meetingRooms.organizationId, orgId))
|
||||
.orderBy(asc(meetingRooms.createdAt));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createRoom(
|
||||
orgId: string,
|
||||
name: string,
|
||||
createdBy: string,
|
||||
): Promise<MeetingRoom> {
|
||||
const [row] = await db
|
||||
.insert(meetingRooms)
|
||||
.values({ organizationId: orgId, name, createdBy })
|
||||
.returning({
|
||||
id: meetingRooms.id,
|
||||
name: meetingRooms.name,
|
||||
createdAt: meetingRooms.createdAt,
|
||||
});
|
||||
return {
|
||||
id: row!.id,
|
||||
name: row!.name,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRoom(orgId: string, roomId: string): Promise<boolean> {
|
||||
const deleted = await db
|
||||
.delete(meetingRooms)
|
||||
.where(
|
||||
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
|
||||
)
|
||||
.returning({ id: meetingRooms.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
// Whether a room exists within the given clinic — used by the realtime layer to
|
||||
// authorize a call:join before relaying any signaling.
|
||||
export async function roomExists(orgId: string, roomId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: meetingRooms.id })
|
||||
.from(meetingRooms)
|
||||
.where(
|
||||
and(eq(meetingRooms.organizationId, orgId), eq(meetingRooms.id, roomId)),
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
// --- Scheduled meetings (calendar) -----------------------------------------
|
||||
|
||||
export type ScheduledMeeting = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
participants: string[];
|
||||
participantNames: string[];
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
// Meetings the user is part of (creator or invited participant), with the
|
||||
// participants' display names resolved for the calendar.
|
||||
export async function listMeetingEvents(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
): Promise<ScheduledMeeting[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
or(
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
// `participants` is a JSONB array of user ids.
|
||||
sql`${scheduledMeetings.participants} @> ${JSON.stringify([userId])}::jsonb`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(scheduledMeetings.date), asc(scheduledMeetings.time));
|
||||
|
||||
// Resolve participant names in one query.
|
||||
const ids = [...new Set(rows.flatMap((r) => r.participants))];
|
||||
const nameById = new Map<string, string>();
|
||||
if (ids.length > 0) {
|
||||
const users = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, ids));
|
||||
for (const u of users) nameById.set(u.id, u.name);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
date: r.date,
|
||||
time: r.time,
|
||||
participants: r.participants,
|
||||
participantNames: r.participants.map((id) => nameById.get(id) ?? "—"),
|
||||
createdBy: r.createdBy,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createMeetingEvent(
|
||||
orgId: string,
|
||||
createdBy: string,
|
||||
input: { title: string; date: string; time: string; participants: string[] },
|
||||
): Promise<ScheduledMeeting> {
|
||||
// The creator is always a participant.
|
||||
const participants = [...new Set([createdBy, ...input.participants])];
|
||||
const [row] = await db
|
||||
.insert(scheduledMeetings)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
title: input.title,
|
||||
date: input.date,
|
||||
time: input.time,
|
||||
participants,
|
||||
createdBy,
|
||||
})
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
const events = await listMeetingEvents(orgId, createdBy);
|
||||
return events.find((e) => e.id === row!.id)!;
|
||||
}
|
||||
|
||||
export async function deleteMeetingEvent(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
// Only the creator can delete.
|
||||
const deleted = await db
|
||||
.delete(scheduledMeetings)
|
||||
.where(
|
||||
and(
|
||||
eq(scheduledMeetings.organizationId, orgId),
|
||||
eq(scheduledMeetings.id, id),
|
||||
eq(scheduledMeetings.createdBy, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: scheduledMeetings.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
@@ -145,10 +145,14 @@ async function buildSummaries(
|
||||
: (others[0]?.name ?? "Conversation"));
|
||||
const lastMessage = lastByConv.get(b.convId) ?? null;
|
||||
const unreadCount = unreadByConv.get(b.convId) ?? 0;
|
||||
// A one-way System notice (e.g. forgot-password alerts): the reserved system
|
||||
// user is a participant. The UI hides the composer/call and styles it apart.
|
||||
const isSystem = participants.some((p) => p.id === SYSTEM_USER_ID);
|
||||
return {
|
||||
id: b.convId,
|
||||
name: displayName,
|
||||
isGroup: b.isGroup,
|
||||
isSystem,
|
||||
participants,
|
||||
lastMessage,
|
||||
unread: unreadCount > 0,
|
||||
@@ -482,6 +486,111 @@ export async function markRead(
|
||||
);
|
||||
}
|
||||
|
||||
// --- System messages -------------------------------------------------------
|
||||
|
||||
// A reserved user that "sends" system messages. It has no account row, so it can
|
||||
// never log in; the messages.senderId FK just needs a user to exist.
|
||||
export const SYSTEM_USER_ID = "system";
|
||||
export const SYSTEM_USER_NAME = "temetro System";
|
||||
|
||||
async function ensureSystemUser(): Promise<void> {
|
||||
await db
|
||||
.insert(user)
|
||||
.values({
|
||||
id: SYSTEM_USER_ID,
|
||||
name: SYSTEM_USER_NAME,
|
||||
email: "system@temetro.local",
|
||||
emailVerified: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// Ensure the per-clinic "System" conversation exists and includes the system
|
||||
// user plus the given recipients, returning its id.
|
||||
async function ensureSystemConversation(
|
||||
orgId: string,
|
||||
recipientIds: string[],
|
||||
): Promise<string> {
|
||||
await ensureSystemUser();
|
||||
const [existing] = await db
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.organizationId, orgId),
|
||||
eq(conversations.name, "System"),
|
||||
eq(conversations.createdBy, SYSTEM_USER_ID),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
let convId = existing?.id;
|
||||
if (!convId) {
|
||||
const [created] = await db
|
||||
.insert(conversations)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
name: "System",
|
||||
isGroup: true,
|
||||
createdBy: SYSTEM_USER_ID,
|
||||
})
|
||||
.returning();
|
||||
convId = created!.id;
|
||||
}
|
||||
const current = new Set(await participantIds(convId));
|
||||
const toAdd = [SYSTEM_USER_ID, ...recipientIds].filter(
|
||||
(id) => !current.has(id),
|
||||
);
|
||||
if (toAdd.length > 0) {
|
||||
await db
|
||||
.insert(conversationParticipants)
|
||||
.values(
|
||||
toAdd.map((uid) => ({
|
||||
conversationId: convId!,
|
||||
userId: uid,
|
||||
lastReadAt: uid === SYSTEM_USER_ID ? new Date() : null,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return convId;
|
||||
}
|
||||
|
||||
// Post a message from the system user into the clinic's System conversation.
|
||||
export async function createSystemMessage(
|
||||
orgId: string,
|
||||
recipientIds: string[],
|
||||
body: string,
|
||||
attachment: MessageAttachment,
|
||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||
const convId = await ensureSystemConversation(orgId, recipientIds);
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
conversationId: convId,
|
||||
senderId: SYSTEM_USER_ID,
|
||||
body,
|
||||
attachments: [attachment],
|
||||
})
|
||||
.returning();
|
||||
await db
|
||||
.update(conversations)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(conversations.id, convId));
|
||||
return {
|
||||
message: {
|
||||
id: row!.id,
|
||||
conversationId: convId,
|
||||
senderId: SYSTEM_USER_ID,
|
||||
senderName: SYSTEM_USER_NAME,
|
||||
body: row!.body,
|
||||
attachments: row!.attachments,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
},
|
||||
recipientIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listClinicMembers(
|
||||
orgId: string,
|
||||
excludeUserId: string,
|
||||
|
||||
@@ -69,6 +69,7 @@ function toPatient(row: PatientRow, children: Children): Patient {
|
||||
labTrend: row.labTrend,
|
||||
encounters: children.encounters,
|
||||
source: row.source,
|
||||
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,6 +427,9 @@ export async function createPatient(
|
||||
userId: string,
|
||||
rawInput: PatientInput,
|
||||
demographicsOnly = false,
|
||||
// Extra columns set on import from a patient wallet (provenance + the
|
||||
// auto-delete deadline for a temporary share).
|
||||
extra?: { shareOrigin?: "wallet" | null; shareExpiresAt?: Date | null },
|
||||
): Promise<Patient> {
|
||||
// Auto-assign a file number when one wasn't supplied (e.g. AI imports).
|
||||
const input: PatientInput = rawInput.fileNumber
|
||||
@@ -438,13 +442,13 @@ export async function createPatient(
|
||||
if (demographicsOnly) {
|
||||
const [row] = await tx
|
||||
.insert(patients)
|
||||
.values(demographicColumns(orgId, input, userId))
|
||||
.values({ ...demographicColumns(orgId, input, userId), ...extra })
|
||||
.returning();
|
||||
return toPatient(row!, emptyChildren());
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(patients)
|
||||
.values(patientColumns(orgId, input, userId))
|
||||
.values({ ...patientColumns(orgId, input, userId), ...extra })
|
||||
.returning();
|
||||
await insertChildren(tx, row!.id, input);
|
||||
return toPatient(row!, childrenFromInput(input));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Discovers this backend's public relay URL from a cloudflared **quick tunnel**
|
||||
// so Dockerized off-network testing is zero-config (`npm run docker:tunnel`).
|
||||
// cloudflared proxies a random `https://<sub>.trycloudflare.com` URL to the
|
||||
// backend and reports it on its metrics endpoint (`GET /quicktunnel`).
|
||||
//
|
||||
// Crucially we only publish that URL once it's actually **reachable from the
|
||||
// public internet** — a fresh quick tunnel returns Cloudflare error 1033 for
|
||||
// ~30s while it propagates to the edge, and baking a not-yet-reachable URL into
|
||||
// the QR is exactly what makes a scan fail with "couldn't reach the clinic".
|
||||
// An explicit PUBLIC_RELAY_URL always wins over this.
|
||||
|
||||
let discovered: string | null = null;
|
||||
let discovery: Promise<void> | null = null;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function getDiscoveredRelayUrl(): string | null {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Kick off discovery once (idempotent). Safe to call at startup.
|
||||
export function beginQuickTunnelDiscovery(metricsUrl: string): Promise<void> {
|
||||
if (!discovery) discovery = runDiscovery(metricsUrl);
|
||||
return discovery;
|
||||
}
|
||||
|
||||
// Return the discovered public URL, waiting up to `capMs` for an in-flight
|
||||
// discovery to finish (so a QR generated right after startup still gets the
|
||||
// tunnel URL rather than a localhost fallback). Null if not ready in time.
|
||||
export async function awaitQuickTunnelUrl(
|
||||
metricsUrl: string,
|
||||
capMs = 25_000,
|
||||
): Promise<string | null> {
|
||||
if (discovered) return discovered;
|
||||
const inFlight = beginQuickTunnelDiscovery(metricsUrl);
|
||||
await Promise.race([inFlight, sleep(capMs)]);
|
||||
return discovered;
|
||||
}
|
||||
|
||||
async function runDiscovery(metricsUrl: string): Promise<void> {
|
||||
const base = metricsUrl.replace(/\/$/, "");
|
||||
const deadline = Date.now() + 150_000;
|
||||
|
||||
// 1) Ask cloudflared for the quick-tunnel hostname.
|
||||
let url: string | null = null;
|
||||
while (!url && Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${base}/quicktunnel`);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { hostname?: string };
|
||||
const host = body.hostname?.trim();
|
||||
if (host) url = host.startsWith("http") ? host : `https://${host}`;
|
||||
}
|
||||
} catch {
|
||||
// cloudflared not up yet (or no tunnel running) — keep trying.
|
||||
}
|
||||
if (!url) await sleep(2000);
|
||||
}
|
||||
if (!url) {
|
||||
console.warn("Cloudflare tunnel: no quick-tunnel URL reported by cloudflared.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Wait until the tunnel is actually reachable end-to-end (edge → cloudflared
|
||||
// → backend) before publishing it, so the QR only ever carries a live URL.
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
discovered = url;
|
||||
console.log(`Wallet relay reachable via Cloudflare tunnel: ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Still propagating (HTTP 1033 / timeout) — retry.
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
// Edge never confirmed within the window; publish best-effort (it usually
|
||||
// comes up shortly) but warn so the cause is visible.
|
||||
discovered = url;
|
||||
console.warn(`Cloudflare tunnel URL set but not confirmed reachable: ${url}`);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { clinicSigningKeys } from "../db/schema/signing.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
import {
|
||||
fingerprint,
|
||||
newSigningKeypair,
|
||||
signMessage,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
|
||||
export type SigningKeyView = {
|
||||
algorithm: string;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
rotatedAt: string | null;
|
||||
};
|
||||
|
||||
type SigningKeyRow = typeof clinicSigningKeys.$inferSelect;
|
||||
|
||||
function toView(row: SigningKeyRow): SigningKeyView {
|
||||
return {
|
||||
algorithm: row.algorithm,
|
||||
publicKey: row.publicKey,
|
||||
fingerprint: row.fingerprint,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
rotatedAt: row.rotatedAt ? row.rotatedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a fresh Ed25519 keypair and upsert it as the clinic's signing key
|
||||
// (rotating overwrites the row and stamps `rotatedAt`). The private key is only
|
||||
// ever stored encrypted (lib/crypto.ts).
|
||||
async function mintKey(orgId: string, rotating: boolean): Promise<SigningKeyView> {
|
||||
const { privateKeyHex, publicKeyHex } = newSigningKeypair();
|
||||
const fp = fingerprint(hexToBytes(publicKeyHex));
|
||||
const [row] = await db
|
||||
.insert(clinicSigningKeys)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
algorithm: "ed25519",
|
||||
publicKey: publicKeyHex,
|
||||
fingerprint: fp,
|
||||
privateKeyEnc: encryptSecret(privateKeyHex),
|
||||
rotatedAt: rotating ? new Date() : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: clinicSigningKeys.organizationId,
|
||||
set: {
|
||||
publicKey: publicKeyHex,
|
||||
fingerprint: fp,
|
||||
privateKeyEnc: encryptSecret(privateKeyHex),
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toView(row!);
|
||||
}
|
||||
|
||||
// The clinic's signing key, creating one on first read so the panel always has
|
||||
// a real key + fingerprint to show.
|
||||
export async function getOrCreateKey(orgId: string): Promise<SigningKeyView> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
if (existing) return toView(existing);
|
||||
return mintKey(orgId, false);
|
||||
}
|
||||
|
||||
export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
return mintKey(orgId, true);
|
||||
}
|
||||
|
||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
orgId: string,
|
||||
message: Uint8Array,
|
||||
): Promise<{ signature: string; publicKey: string }> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
if (!row) {
|
||||
const view = await mintKey(orgId, false);
|
||||
const [fresh] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return {
|
||||
signature: signMessage(decryptSecret(fresh!.privateKeyEnc), message),
|
||||
publicKey: view.publicKey,
|
||||
};
|
||||
}
|
||||
return {
|
||||
signature: signMessage(decryptSecret(row.privateKeyEnc), message),
|
||||
publicKey: row.publicKey,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function toTask(row: TaskRow): Task {
|
||||
title: row.title,
|
||||
assignee: row.assignee,
|
||||
assigneeRole: row.assigneeRole,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
due: row.due,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
@@ -48,7 +49,11 @@ export async function listTasks(
|
||||
|
||||
let where = eq(tasks.organizationId, orgId);
|
||||
if (!isAdmin) {
|
||||
const visible = [eq(tasks.createdBy, viewer.userId)];
|
||||
const visible = [
|
||||
eq(tasks.createdBy, viewer.userId),
|
||||
// Tasks assigned to this person specifically.
|
||||
eq(tasks.assigneeUserId, viewer.userId),
|
||||
];
|
||||
if (roles.length) visible.push(inArray(tasks.assigneeRole, roles));
|
||||
where = and(where, or(...visible))!;
|
||||
}
|
||||
@@ -73,6 +78,7 @@ export async function createTask(
|
||||
title: input.title,
|
||||
assignee: input.assignee,
|
||||
assigneeRole: input.assigneeRole ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
due: input.due,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
@@ -100,6 +106,8 @@ export async function updateTask(
|
||||
if (patch.assignee !== undefined) set.assignee = patch.assignee;
|
||||
if (patch.assigneeRole !== undefined)
|
||||
set.assigneeRole = patch.assigneeRole ?? null;
|
||||
if (patch.assigneeUserId !== undefined)
|
||||
set.assigneeUserId = patch.assigneeUserId ?? null;
|
||||
if (patch.due !== undefined) set.due = patch.due;
|
||||
if (patch.priority !== undefined) set.priority = patch.priority;
|
||||
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { and, eq, isNotNull, lte } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { patients } from "../db/schema/patients.js";
|
||||
import {
|
||||
walletShareRequests,
|
||||
type WalletShareMode,
|
||||
} from "../db/schema/wallet-share.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import {
|
||||
decodeWalletNumber,
|
||||
newEncryptionKeypair,
|
||||
open,
|
||||
verifySignature,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
import type { Patient } from "../types/patient.js";
|
||||
import { recordActivity } from "./activity.js";
|
||||
import { deletePatient } from "./patients.js";
|
||||
|
||||
type ShareRow = typeof walletShareRequests.$inferSelect;
|
||||
|
||||
export type ShareRequestView = {
|
||||
id: string;
|
||||
walletNumber: string;
|
||||
status: ShareRow["status"];
|
||||
shareMode: WalletShareMode;
|
||||
shareExpiresAt: string | null;
|
||||
draft: Patient | null;
|
||||
};
|
||||
|
||||
function toView(row: ShareRow): ShareRequestView {
|
||||
return {
|
||||
id: row.id,
|
||||
walletNumber: row.walletNumber ?? "",
|
||||
status: row.status,
|
||||
shareMode: row.shareMode,
|
||||
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
|
||||
draft: row.draft ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a QR "scan to connect" pairing request — no wallet number yet (it is
|
||||
// bound when the scanning device responds). Mints the ephemeral keypair the
|
||||
// device seals to; the pairing URI (relay + id + ephemeral key) goes in the QR.
|
||||
export async function createPairingRequest(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
mode: WalletShareMode,
|
||||
durationHours?: number,
|
||||
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
|
||||
const { privateKeyHex, publicKeyHex } = newEncryptionKeypair();
|
||||
const shareExpiresAt =
|
||||
mode === "temporary" && durationHours
|
||||
? new Date(Date.now() + durationHours * 3_600_000)
|
||||
: null;
|
||||
const [row] = await db
|
||||
.insert(walletShareRequests)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
requestedBy: userId,
|
||||
walletNumber: null,
|
||||
ephemeralPubKey: publicKeyHex,
|
||||
ephemeralPrivEnc: encryptSecret(privateKeyHex),
|
||||
shareMode: mode,
|
||||
shareExpiresAt,
|
||||
})
|
||||
.returning();
|
||||
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
|
||||
}
|
||||
|
||||
// Create an import request: validate the wallet number, mint a per-request
|
||||
// ephemeral X25519 keypair (the phone seals the bundle to its public key) and
|
||||
// store the request. Returns the row + the ephemeral public key to relay to the
|
||||
// device. Throws 400 on a malformed wallet number.
|
||||
export async function createShareRequest(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
walletNumber: string,
|
||||
mode: WalletShareMode,
|
||||
durationHours?: number,
|
||||
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
|
||||
try {
|
||||
decodeWalletNumber(walletNumber);
|
||||
} catch (err) {
|
||||
throw new HttpError(400, (err as Error).message);
|
||||
}
|
||||
const { privateKeyHex, publicKeyHex } = newEncryptionKeypair();
|
||||
const shareExpiresAt =
|
||||
mode === "temporary" && durationHours
|
||||
? new Date(Date.now() + durationHours * 3_600_000)
|
||||
: null;
|
||||
const [row] = await db
|
||||
.insert(walletShareRequests)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
requestedBy: userId,
|
||||
walletNumber: walletNumber.trim(),
|
||||
ephemeralPubKey: publicKeyHex,
|
||||
ephemeralPrivEnc: encryptSecret(privateKeyHex),
|
||||
shareMode: mode,
|
||||
shareExpiresAt,
|
||||
})
|
||||
.returning();
|
||||
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
|
||||
}
|
||||
|
||||
export async function getShareRequest(
|
||||
orgId: string,
|
||||
id: string,
|
||||
): Promise<ShareRequestView | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.id, id),
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
return row ? toView(row) : null;
|
||||
}
|
||||
|
||||
// Recent import requests for the clinic — feeds the Signing panel's "Signed
|
||||
// records" / shared-records list.
|
||||
export async function listShareRequests(
|
||||
orgId: string,
|
||||
limit = 20,
|
||||
): Promise<ShareRequestView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.organizationId, orgId))
|
||||
.orderBy(walletShareRequests.createdAt)
|
||||
.limit(limit);
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Apply a response relayed back from the patient's device. On approval we
|
||||
// decrypt the sealed bundle with the request's ephemeral private key and verify
|
||||
// the wallet's Ed25519 signature over it (provenance: it really came from that
|
||||
// wallet number). Returns the resolved view, or null when the request is unknown
|
||||
// / already resolved. Throws on a tampered/forged bundle.
|
||||
export async function applyShareResponse(
|
||||
requestId: string,
|
||||
walletNumber: string,
|
||||
decision: "approved" | "denied",
|
||||
sealed?: string,
|
||||
signatureHex?: string,
|
||||
): Promise<ShareRequestView | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.id, requestId));
|
||||
if (!row || row.status !== "pending") return null;
|
||||
// Typed flow: the responder must match the wallet the clinic addressed.
|
||||
// QR pairing flow (stored wallet number is null): bind it to the
|
||||
// authenticated responder now.
|
||||
if (row.walletNumber && row.walletNumber !== walletNumber.trim()) return null;
|
||||
|
||||
if (decision === "denied") {
|
||||
const [updated] = await db
|
||||
.update(walletShareRequests)
|
||||
.set({
|
||||
status: "denied",
|
||||
resolvedAt: new Date(),
|
||||
walletNumber: walletNumber.trim(),
|
||||
})
|
||||
.where(eq(walletShareRequests.id, requestId))
|
||||
.returning();
|
||||
return updated ? toView(updated) : null;
|
||||
}
|
||||
|
||||
if (!sealed || !signatureHex) {
|
||||
throw new HttpError(400, "Approval is missing the sealed record bundle.");
|
||||
}
|
||||
const plaintext = open(decryptSecret(row.ephemeralPrivEnc), sealed);
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
if (!verifySignature(publicKey, signatureHex, plaintext)) {
|
||||
throw new HttpError(400, "Bundle signature did not match the wallet number.");
|
||||
}
|
||||
const bundle = JSON.parse(Buffer.from(plaintext).toString("utf8")) as {
|
||||
patient: Patient;
|
||||
};
|
||||
|
||||
const [updated] = await db
|
||||
.update(walletShareRequests)
|
||||
.set({
|
||||
status: "approved",
|
||||
resolvedAt: new Date(),
|
||||
draft: bundle.patient,
|
||||
walletNumber: walletNumber.trim(),
|
||||
})
|
||||
.where(eq(walletShareRequests.id, requestId))
|
||||
.returning();
|
||||
return updated ? toView(updated) : null;
|
||||
}
|
||||
|
||||
// Record the file number once a clinic commits the imported draft, so a later
|
||||
// revoke from the device can delete exactly that record.
|
||||
export async function markCommitted(
|
||||
orgId: string,
|
||||
id: string,
|
||||
fileNumber: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(walletShareRequests)
|
||||
.set({ committedFileNumber: fileNumber })
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.id, id),
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Patient-initiated revoke from the app: find the committed import for this
|
||||
// request + wallet and hard-delete that patient from the clinic. Returns the
|
||||
// org/fileNumber deleted (for an activity entry), or null.
|
||||
export async function revokeShare(
|
||||
requestId: string,
|
||||
walletNumber: string,
|
||||
): Promise<{ orgId: string; fileNumber: string } | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.id, requestId));
|
||||
if (!row || row.walletNumber !== walletNumber.trim()) return null;
|
||||
if (!row.committedFileNumber) return null;
|
||||
const ok = await deletePatient(row.organizationId, row.committedFileNumber);
|
||||
if (!ok) return null;
|
||||
await recordActivity({
|
||||
orgId: row.organizationId,
|
||||
actor: { id: row.requestedBy, name: "Patient wallet" },
|
||||
action: `Patient revoked shared record #${row.committedFileNumber}`,
|
||||
entityType: "patient",
|
||||
entityId: row.committedFileNumber,
|
||||
patientFileNumber: row.committedFileNumber,
|
||||
}).catch(() => {});
|
||||
return { orgId: row.organizationId, fileNumber: row.committedFileNumber };
|
||||
}
|
||||
|
||||
// Deployment-wide sweep: hard-delete any temporarily-shared patient whose
|
||||
// share window has passed. Called on an interval from index.ts.
|
||||
export async function sweepExpiredShares(): Promise<number> {
|
||||
const expired = await db
|
||||
.select({
|
||||
organizationId: patients.organizationId,
|
||||
fileNumber: patients.fileNumber,
|
||||
})
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(patients.shareExpiresAt),
|
||||
lte(patients.shareExpiresAt, new Date()),
|
||||
),
|
||||
);
|
||||
for (const p of expired) {
|
||||
await deletePatient(p.organizationId, p.fileNumber);
|
||||
await recordActivity({
|
||||
orgId: p.organizationId,
|
||||
actor: { id: "system", name: "temetro" },
|
||||
action: `Temporary shared record #${p.fileNumber} expired and was deleted`,
|
||||
entityType: "patient",
|
||||
entityId: p.fileNumber,
|
||||
patientFileNumber: p.fileNumber,
|
||||
}).catch(() => {});
|
||||
}
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
// Build the canonical bytes a wallet signs / the clinic verifies for a bundle.
|
||||
export function bundleBytes(patient: Patient): Uint8Array {
|
||||
return utf8ToBytes(JSON.stringify({ patient }));
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export type ActivityEntityType =
|
||||
| "invoice"
|
||||
| "inventory"
|
||||
| "dispense"
|
||||
| "task";
|
||||
| "task"
|
||||
| "settings";
|
||||
|
||||
export type ActivityEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -25,7 +25,16 @@ export type MessageAttachment =
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot };
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot }
|
||||
// A system-generated alert (e.g. an employee asked for a password reset but no
|
||||
// email provider is configured). Rendered as a distinct "System" card that
|
||||
// deep-links an admin to the member's settings.
|
||||
| {
|
||||
kind: "passwordReset";
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
@@ -41,6 +50,7 @@ export type ConversationSummary = {
|
||||
id: string;
|
||||
name: string; // display name: group name, or the other participant for a DM
|
||||
isGroup: boolean;
|
||||
isSystem: boolean; // a one-way System notice (no replies / calls)
|
||||
participants: Participant[];
|
||||
lastMessage: ConversationMessage | null;
|
||||
unread: boolean;
|
||||
|
||||
@@ -71,4 +71,7 @@ export type Patient = {
|
||||
labTrend: Trend; // headline lab plotted as a sparkline
|
||||
encounters: Encounter[];
|
||||
source?: "manual" | "ai"; // "ai" = imported/drafted by the chat agent
|
||||
// Set when this record was imported from a patient wallet as a *temporary*
|
||||
// share — the ISO deadline after which it is auto-deleted from the clinic.
|
||||
shareExpiresAt?: string | null;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ export type Task = {
|
||||
assignee: string;
|
||||
// Department (member role) the task is assigned to; null = personal task.
|
||||
assigneeRole: string | null;
|
||||
// A specific person the task is assigned to (user id); null when assigned to
|
||||
// a department or kept personal. Takes precedence over assigneeRole for who
|
||||
// sees the task.
|
||||
assigneeUserId: string | null;
|
||||
due: string;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
|
||||
+5
-2
@@ -13,8 +13,11 @@ RUN npm ci --no-audit --no-fund \
|
||||
# --- build (Next.js standalone output) -------------------------------------
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
# NEXT_PUBLIC_* values are inlined at build time.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
# NEXT_PUBLIC_* values are inlined at build time. We deliberately leave the API
|
||||
# URL EMPTY by default: the app derives the backend URL from the browser host at
|
||||
# runtime (see lib/backend-url.ts), so one prebuilt image works on localhost and
|
||||
# any clinic LAN IP. Set NEXT_PUBLIC_API_URL only to pin a fixed/proxied URL.
|
||||
ARG NEXT_PUBLIC_API_URL=
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
+40
-23
@@ -1,36 +1,53 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# temetro frontend
|
||||
|
||||
## Getting Started
|
||||
The clinician-facing **AI chat UI** for [temetro](../) — a Next.js 16 app where
|
||||
clinicians retrieve and organize patient data in natural language, rendered as
|
||||
rich record cards. It's wired to the [`../backend`](../backend) for real auth,
|
||||
multi-tenant clinics, and live patient data.
|
||||
|
||||
First, run the development server:
|
||||

|
||||
|
||||
## Stack
|
||||
|
||||
Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 ·
|
||||
[COSS](https://coss.dev) UI components (Base UI) · i18next · Socket.io client.
|
||||
|
||||
> This app runs a **customized Next.js 16** whose conventions differ from the
|
||||
> public docs (e.g. route protection lives in `proxy.ts`, not `middleware.ts`).
|
||||
> See [`CLAUDE.md`](./CLAUDE.md) and `node_modules/next/dist/docs/` before
|
||||
> writing Next.js code.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
npm install
|
||||
npm run dev # Next dev server (Turbopack) on http://localhost:3000
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Other scripts: `npm run build` (production build), `npm run start` (serve the
|
||||
build), `npm run lint`. There is no test runner — verify changes by running the
|
||||
dev server.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### Talking to the backend
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
The frontend needs the API running (see [`../backend`](../backend)). It resolves
|
||||
the backend URL **from the host you open the app on** — so it works on
|
||||
`localhost` and across the clinic LAN (`http://<server-IP>:3000`) without a
|
||||
rebuild. Set `NEXT_PUBLIC_API_URL` only to pin a fixed or reverse-proxied URL;
|
||||
see [`lib/backend-url.ts`](./lib/backend-url.ts).
|
||||
|
||||
## Learn More
|
||||
## Architecture
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
- **`app/`** — App Router. `app/(app)/` is the authenticated product shell;
|
||||
`app/(auth)/` holds the login / signup / onboarding pages.
|
||||
- **`components/chat/`** — the chat UI (input, message state, patient cards).
|
||||
- **`components/settings/`** — settings panels, including **About & updates**
|
||||
(version + LAN access).
|
||||
- **`components/ui/`** — COSS primitives (Base UI, added via the shadcn CLI).
|
||||
- **`lib/`** — API + auth clients, i18n, and data helpers.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
See [`CLAUDE.md`](./CLAUDE.md) for the full architecture, theming, and gotchas.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## License
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
[MIT](./LICENSE).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AppAuthGuard } from "@/components/auth/app-auth-guard";
|
||||
import { CommandPaletteProvider } from "@/components/command-palette";
|
||||
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
|
||||
import { MobileSidebarTrigger } from "@/components/sidebar-02/mobile-sidebar-trigger";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { UpdateBanner } from "@/components/update-banner";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@@ -14,7 +16,9 @@ export default function AppLayout({
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
<MobileSidebarTrigger />
|
||||
{children}
|
||||
<UpdateBanner />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</CommandPaletteProvider>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MeetingsView } from "@/components/meetings/meetings-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function MeetingsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
<MeetingsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,19 @@ import { useTranslation } from "react-i18next";
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { requestResetByUsername } from "@/lib/auth-helpers";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
type Mode = "email" | "username";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
// Owners reset by the email they signed up with; admin-provisioned staff reset
|
||||
// by their username (their email may be a synthetic placeholder).
|
||||
const [mode, setMode] = useState<Mode>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -23,18 +30,33 @@ export default function ForgotPasswordPage() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const { error: err } = await authClient.requestPasswordReset({
|
||||
email: email.trim(),
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
});
|
||||
const redirectTo = `${window.location.origin}/reset-password`;
|
||||
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
if (mode === "email") {
|
||||
const { error: err } = await authClient.requestPasswordReset({
|
||||
email: identifier.trim(),
|
||||
redirectTo,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await requestResetByUsername(identifier.trim(), redirectTo);
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
const message = t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
notify.success(
|
||||
t("auth.forgotPassword.sentToastTitle"),
|
||||
t("auth.forgotPassword.sentToastBody"),
|
||||
@@ -54,20 +76,50 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
{sent ? (
|
||||
<FormAlert tone="success">
|
||||
{t("auth.forgotPassword.sent", { email })}
|
||||
{mode === "email"
|
||||
? t("auth.forgotPassword.sent", { email: identifier })
|
||||
: t("auth.forgotPassword.sentUsername")}
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label={t("auth.forgotPassword.emailLabel")}>
|
||||
<Tabs
|
||||
onValueChange={(value) => {
|
||||
setMode(value as Mode);
|
||||
setError(null);
|
||||
setIdentifier("");
|
||||
}}
|
||||
value={mode}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTab className="flex-1" value="email">
|
||||
{t("auth.forgotPassword.modeEmail")}
|
||||
</TabsTab>
|
||||
<TabsTab className="flex-1" value="username">
|
||||
{t("auth.forgotPassword.modeUsername")}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Field
|
||||
htmlFor="identifier"
|
||||
label={
|
||||
mode === "email"
|
||||
? t("auth.forgotPassword.emailLabel")
|
||||
: t("auth.forgotPassword.usernameLabel")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.forgotPassword.emailPlaceholder")}
|
||||
autoComplete={mode === "email" ? "email" : "username"}
|
||||
id="identifier"
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder={
|
||||
mode === "email"
|
||||
? t("auth.forgotPassword.emailPlaceholder")
|
||||
: t("auth.forgotPassword.usernamePlaceholder")
|
||||
}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
type={mode === "email" ? "email" : "text"}
|
||||
value={identifier}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// The Patient Portal kiosk runs with no app chrome — no sidebar, no auth guard.
|
||||
// It's a standalone full-screen surface for a clinic iPad / self-service device.
|
||||
export default function PortalLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <main className="min-h-dvh w-full overflow-y-auto bg-background">{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { PortalKiosk } from "@/components/portal/portal-kiosk";
|
||||
|
||||
export default function PatientPortalPage() {
|
||||
const params = useParams<{ clinic: string }>();
|
||||
const clinic = Array.isArray(params.clinic) ? params.clinic[0] : params.clinic;
|
||||
return <PortalKiosk clinic={clinic ?? ""} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -11,10 +12,49 @@ import { BarXAxis } from "@/components/charts/bar-x-axis";
|
||||
import { Grid } from "@/components/charts/grid";
|
||||
import { ChartTooltip } from "@/components/charts/tooltip";
|
||||
import { XAxis } from "@/components/charts/x-axis";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverPopup,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type Analytics, getAnalytics } from "@/lib/analytics";
|
||||
import { type Appointment, listAppointments } from "@/lib/appointments";
|
||||
import { formatMoney } from "@/lib/invoices";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Time-range filter for the Overview header. Month-based series are sliced to
|
||||
// the trailing window; sub-month ranges (30d/today) fall back to the latest
|
||||
// point since the backend has no finer-grained series yet.
|
||||
const RANGES = ["all", "3m", "30d", "today"] as const;
|
||||
type Range = (typeof RANGES)[number];
|
||||
const RANGE_MONTHS: Record<Range, number | null> = {
|
||||
all: null,
|
||||
"3m": 3,
|
||||
"30d": 1,
|
||||
today: 1,
|
||||
};
|
||||
// Charts use monthly aggregates, so a sub-month window would collapse to a
|
||||
// single point (which an area chart can't draw). Keep at least two trailing
|
||||
// points so every range renders a valid mini-trend.
|
||||
function sliceMonths<T>(arr: T[], range: Range): T[] {
|
||||
const months = RANGE_MONTHS[range];
|
||||
if (months == null) return arr;
|
||||
return arr.slice(-Math.max(months, 2));
|
||||
}
|
||||
|
||||
// The Overview sections the Customize popover can show/hide.
|
||||
const SECTION_KEYS = [
|
||||
"visits",
|
||||
"patients",
|
||||
"trends",
|
||||
"earnings",
|
||||
"appointments",
|
||||
"prescriptions",
|
||||
] as const;
|
||||
type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
// Clinic analytics computed on the server from real data (patients,
|
||||
// appointments, prescriptions, tasks). No fabricated financials — temetro has no
|
||||
@@ -91,6 +131,15 @@ export function AnalysisView() {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<Analytics | null>(null);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [range, setRange] = useState<Range>("all");
|
||||
const [visible, setVisible] = useState<Record<SectionKey, boolean>>({
|
||||
visits: true,
|
||||
patients: true,
|
||||
trends: true,
|
||||
earnings: true,
|
||||
appointments: true,
|
||||
prescriptions: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -135,7 +184,11 @@ export function AnalysisView() {
|
||||
}
|
||||
return months;
|
||||
}, [appointments]);
|
||||
const visitTotal = visitData.reduce((sum, p) => sum + p.visits, 0);
|
||||
const visitDataRanged = useMemo(
|
||||
() => sliceMonths(visitData, range),
|
||||
[visitData, range],
|
||||
);
|
||||
const visitTotal = visitDataRanged.reduce((sum, p) => sum + p.visits, 0);
|
||||
|
||||
// The area chart needs real Date x-values: synthesise one month per point,
|
||||
// ending with the current month.
|
||||
@@ -162,86 +215,162 @@ export function AnalysisView() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const monthTotal = monthData.reduce((sum, p) => sum + p.patients, 0);
|
||||
const monthDataRanged = useMemo(
|
||||
() => sliceMonths(monthData, range),
|
||||
[monthData, range],
|
||||
);
|
||||
const earningsByMonthRanged = sliceMonths(
|
||||
data?.earnings.byMonth ?? [],
|
||||
range,
|
||||
);
|
||||
const monthTotal = monthDataRanged.reduce((sum, p) => sum + p.patients, 0);
|
||||
const weekdayTotal = weekdayData.reduce((sum, p) => sum + p.appointments, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("analysis.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("analysis.subtitle")}</p>
|
||||
{/* Overview header: title + time-range segmented control + Customize. */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("analysis.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-full border bg-muted/40 p-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 font-medium text-sm transition-colors",
|
||||
range === r
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
type="button"
|
||||
>
|
||||
{t(`analysis.range.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="sm" variant="outline">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{t("analysis.customize")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverPopup className="w-56">
|
||||
<p className="mb-2 font-medium text-sm">
|
||||
{t("analysis.customizeTitle")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SECTION_KEYS.map((key) => (
|
||||
<label
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
key={key}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`analysis.section.${key}`)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible[key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setVisible((prev) => ({ ...prev, [key]: checked }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPopup>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.area.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.area.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="gap-3 p-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.area.label")}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground text-xl tabular-nums">
|
||||
{visitTotal}
|
||||
</span>
|
||||
{visible.visits && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.area.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.area.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<AreaChart aspectRatio="3 / 1" data={visitData}>
|
||||
<Grid horizontal />
|
||||
<Area dataKey="visits" fill="var(--chart-line-primary)" />
|
||||
<XAxis numTicks={Math.max(visitData.length, 2)} tickMode="data" />
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</AreaChart>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Section
|
||||
columns={3}
|
||||
description={t("analysis.patientVolume.description")}
|
||||
title={t("analysis.patientVolume.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.total")}
|
||||
value={n(data?.patients.total)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.newThisMonth")}
|
||||
value={n(data?.patients.newThisMonth)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.active")}
|
||||
value={n(data?.patients.active)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.charts.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.charts.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ChartCard
|
||||
title={t("analysis.charts.patientGrowthTitle")}
|
||||
total={monthTotal}
|
||||
>
|
||||
<AreaChart aspectRatio="2 / 1" data={monthData}>
|
||||
<Card className="gap-3 p-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.area.label")}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground text-xl tabular-nums">
|
||||
{visitTotal}
|
||||
</span>
|
||||
</div>
|
||||
<AreaChart aspectRatio="3 / 1" data={visitDataRanged}>
|
||||
<Grid horizontal />
|
||||
<Area dataKey="patients" fill="var(--chart-line-primary)" />
|
||||
{/* One tick per month so every point is labelled. */}
|
||||
<XAxis numTicks={Math.max(monthData.length, 2)} tickMode="data" />
|
||||
<Area dataKey="visits" fill="var(--chart-line-primary)" />
|
||||
<XAxis
|
||||
numTicks={Math.max(visitDataRanged.length, 2)}
|
||||
tickMode="data"
|
||||
/>
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</AreaChart>
|
||||
</ChartCard>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visible.patients && (
|
||||
<Section
|
||||
columns={3}
|
||||
description={t("analysis.patientVolume.description")}
|
||||
title={t("analysis.patientVolume.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.total")}
|
||||
value={n(data?.patients.total)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.newThisMonth")}
|
||||
value={n(data?.patients.newThisMonth)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.patientVolume.active")}
|
||||
value={n(data?.patients.active)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{visible.trends && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.charts.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.charts.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ChartCard
|
||||
title={t("analysis.charts.patientGrowthTitle")}
|
||||
total={monthTotal}
|
||||
>
|
||||
<AreaChart aspectRatio="2 / 1" data={monthDataRanged}>
|
||||
<Grid horizontal />
|
||||
<Area dataKey="patients" fill="var(--chart-line-primary)" />
|
||||
{/* One tick per month so every point is labelled. */}
|
||||
<XAxis
|
||||
numTicks={Math.max(monthDataRanged.length, 2)}
|
||||
tickMode="data"
|
||||
/>
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</AreaChart>
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title={t("analysis.charts.weeklyAppointmentsTitle")}
|
||||
total={weekdayTotal}
|
||||
@@ -255,9 +384,11 @@ export function AnalysisView() {
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visible.earnings && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
@@ -285,47 +416,52 @@ export function AnalysisView() {
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.earnings.byMonth")}
|
||||
</span>
|
||||
<EarningsChart data={data?.earnings.byMonth ?? []} />
|
||||
<EarningsChart data={earningsByMonthRanged} />
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
columns={4}
|
||||
description={t("analysis.appointments.description")}
|
||||
title={t("analysis.appointments.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.thisWeek")}
|
||||
value={n(data?.appointments.thisWeek)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.upcoming")}
|
||||
value={n(data?.appointments.upcoming)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.completed")}
|
||||
value={n(data?.appointments.completed)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.cancelled")}
|
||||
value={n(data?.appointments.cancelled)}
|
||||
/>
|
||||
</Section>
|
||||
{visible.appointments && (
|
||||
<Section
|
||||
columns={4}
|
||||
description={t("analysis.appointments.description")}
|
||||
title={t("analysis.appointments.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.thisWeek")}
|
||||
value={n(data?.appointments.thisWeek)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.upcoming")}
|
||||
value={n(data?.appointments.upcoming)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.completed")}
|
||||
value={n(data?.appointments.completed)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.appointments.cancelled")}
|
||||
value={n(data?.appointments.cancelled)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
columns={2}
|
||||
description={t("analysis.prescriptions.description")}
|
||||
title={t("analysis.prescriptions.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.prescriptions.total")}
|
||||
value={n(data?.prescriptions.total)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.prescriptions.active")}
|
||||
value={n(data?.prescriptions.active)}
|
||||
/>
|
||||
</Section>
|
||||
{visible.prescriptions && (
|
||||
<Section
|
||||
columns={2}
|
||||
description={t("analysis.prescriptions.description")}
|
||||
title={t("analysis.prescriptions.title")}
|
||||
>
|
||||
<StatCard
|
||||
label={t("analysis.prescriptions.total")}
|
||||
value={n(data?.prescriptions.total)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.prescriptions.active")}
|
||||
value={n(data?.prescriptions.active)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
columns={2}
|
||||
|
||||
@@ -34,12 +34,23 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
|
||||
// Signed in but no active clinic selected yet.
|
||||
if (orgsPending) return;
|
||||
// A setActive is already in flight (e.g. just after creating a clinic) — wait
|
||||
// for it rather than treating the momentarily-empty list as "no clinics" and
|
||||
// bouncing the user back to onboarding.
|
||||
if (settingActive.current) return;
|
||||
const first = orgs?.[0];
|
||||
if (first) {
|
||||
if (!settingActive.current) {
|
||||
settingActive.current = true;
|
||||
void authClient.organization.setActive({ organizationId: first.id });
|
||||
}
|
||||
settingActive.current = true;
|
||||
void authClient.organization
|
||||
.setActive({ organizationId: first.id })
|
||||
// Refresh the cached session so activeOrganizationId is populated and the
|
||||
// guard re-renders into the ready state.
|
||||
.then(() =>
|
||||
authClient.getSession({ query: { disableCookieCache: true } }),
|
||||
)
|
||||
.catch(() => {
|
||||
settingActive.current = false;
|
||||
});
|
||||
} else {
|
||||
router.replace("/onboarding");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { History, Plus, Search, SquarePen, Trash2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { type MouseEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetPanel,
|
||||
SheetPopup,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
deleteThread,
|
||||
listThreads,
|
||||
THREADS_CHANGED_EVENT,
|
||||
type ThreadSummary,
|
||||
} from "@/lib/ai-chat-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// The pill (panel toggle + search) that sits top-left of the AI chat, next to
|
||||
// the sidebar. Opens a sheet listing saved chats with a "Start new chat" button
|
||||
// — so chat history is reachable from inside the chat, not just the sidebar.
|
||||
export function ChatHistoryPanel() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeThread = searchParams.get("thread");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [threads, setThreads] = useState<ThreadSummary[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch(() => {
|
||||
/* not signed in / no clinic — show nothing */
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return threads;
|
||||
return threads.filter((x) => x.title.toLowerCase().includes(q));
|
||||
}, [threads, query]);
|
||||
|
||||
const go = (href: string) => {
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
const remove = async (event: MouseEvent, id: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setThreads((prev) => prev.filter((x) => x.id !== id));
|
||||
await deleteThread(id).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-0.5 rounded-full border bg-card/40 p-0.5">
|
||||
<button
|
||||
aria-label={t("chat.history.open")}
|
||||
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<History className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={t("chat.history.startNew")}
|
||||
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => router.push("/")}
|
||||
type="button"
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetPopup side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("chat.history.title")}</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("chat.history.open")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetPanel className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<Button className="w-full justify-start" onClick={() => go("/")}>
|
||||
<Plus className="size-4" />
|
||||
{t("chat.history.startNew")}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("chat.history.search")}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
||||
{t("chat.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((thread) => {
|
||||
const active = activeThread === thread.id;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
key={thread.id}
|
||||
onClick={() => go(`/?thread=${thread.id}`)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{thread.title}
|
||||
</span>
|
||||
<span
|
||||
aria-label={t("chat.history.delete")}
|
||||
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
onClick={(event) => remove(event, thread.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SheetPanel>
|
||||
</SheetPopup>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -25,7 +26,37 @@ type ChatInputProps = {
|
||||
};
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
|
||||
|
||||
// Minimal Web Speech API typings — not in this TS lib.dom. We only use a slice.
|
||||
interface SpeechRecognitionEventLike {
|
||||
readonly results: {
|
||||
readonly length: number;
|
||||
[index: number]: { readonly [index: number]: { transcript: string } };
|
||||
};
|
||||
}
|
||||
interface SpeechRecognitionLike {
|
||||
lang: string;
|
||||
interimResults: boolean;
|
||||
continuous: boolean;
|
||||
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
|
||||
// Web Speech API lives under a vendor prefix in Chromium-based browsers and is
|
||||
// absent in others (e.g. Firefox). Resolve the constructor or null.
|
||||
function getSpeechRecognition(): SpeechRecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as typeof window & {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
@@ -47,6 +78,53 @@ export function ChatInput({
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
|
||||
// first client render agree (button starts disabled, enabled by the effect).
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
// The textarea contents when dictation started; transcript is appended to it.
|
||||
const dictationBaseRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
setSpeechSupported(getSpeechRecognition() !== null);
|
||||
return () => recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop();
|
||||
return;
|
||||
}
|
||||
const Recognition = getSpeechRecognition();
|
||||
if (!Recognition) return;
|
||||
|
||||
const recognition = new Recognition();
|
||||
recognitionRef.current = recognition;
|
||||
recognition.lang = navigator.language || "en-US";
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
// Continue from where the text leaves off, with a separating space.
|
||||
dictationBaseRef.current = value ? `${value.replace(/\s*$/, "")} ` : "";
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let transcript = "";
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i]?.[0]?.transcript ?? "";
|
||||
}
|
||||
setValue(dictationBaseRef.current + transcript);
|
||||
};
|
||||
const end = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognition.onend = end;
|
||||
recognition.onerror = end;
|
||||
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [isListening, value]);
|
||||
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
@@ -183,8 +261,22 @@ export function ChatInput({
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
/>
|
||||
<button
|
||||
aria-label={t("chat.input.dictate")}
|
||||
className={iconButton}
|
||||
aria-label={
|
||||
isListening ? t("chat.input.dictateStop") : t("chat.input.dictate")
|
||||
}
|
||||
aria-pressed={isListening}
|
||||
className={cn(
|
||||
iconButton,
|
||||
isListening &&
|
||||
"animate-pulse bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive"
|
||||
)}
|
||||
disabled={!speechSupported}
|
||||
onClick={toggleDictation}
|
||||
title={
|
||||
speechSupported
|
||||
? t("chat.input.dictate")
|
||||
: t("chat.input.dictateUnsupported")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Mic className="size-[18px]" />
|
||||
|
||||
@@ -29,11 +29,12 @@ import {
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
CitedResponse,
|
||||
hasCitationMarkers,
|
||||
SourcesFooter,
|
||||
} from "@/components/chat/message-citations";
|
||||
import {
|
||||
Queue,
|
||||
QueueItem,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
|
||||
import { AnalyticsCard } from "@/components/chat/analytics-card";
|
||||
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
|
||||
import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { ClinicCard } from "@/components/chat/clinic-card";
|
||||
import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
||||
@@ -467,6 +469,12 @@ export function ChatPanel() {
|
||||
</Queue>
|
||||
) : null;
|
||||
|
||||
// Veil runs once per conversation, so the "Veil active" chip should only show
|
||||
// on the first assistant message that carries a veilNotice — not every turn.
|
||||
const firstVeilMessageId = messages.find((m) =>
|
||||
m.parts.some((p) => p.type === "data-veilNotice"),
|
||||
)?.id;
|
||||
|
||||
// Render one assistant/user message: a Chain-of-Thought trace built from any
|
||||
// `data-step` parts, then the rest of the parts (text + record cards) in order.
|
||||
const renderMessage = (message: TemetroUIMessage, isLast: boolean) => {
|
||||
@@ -483,6 +491,15 @@ export function ChatPanel() {
|
||||
// Attachments the clinician uploaded — rendered once as a chip group.
|
||||
const fileParts = message.parts.filter((p) => p.type === "file");
|
||||
const firstFileIdx = message.parts.findIndex((p) => p.type === "file");
|
||||
// Citable sources the agent retrieved for this message; the model references
|
||||
// them inline via [[src:id]] markers (rendered as chips). When it emits no
|
||||
// markers, a sources footer still attributes the retrieved records.
|
||||
const sources = message.parts
|
||||
.filter((p) => p.type === "data-source")
|
||||
.map((p) => p.data);
|
||||
const hasInlineCitations = message.parts.some(
|
||||
(p) => p.type === "text" && hasCitationMarkers(p.text),
|
||||
);
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent className="w-full">
|
||||
@@ -543,7 +560,7 @@ export function ChatPanel() {
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<MessageResponse key={key}>{part.text}</MessageResponse>
|
||||
<CitedResponse key={key} sources={sources} text={part.text} />
|
||||
);
|
||||
}
|
||||
if (part.type === "file") {
|
||||
@@ -660,6 +677,8 @@ export function ChatPanel() {
|
||||
return <AnalyticsCard data={part.data} key={key} />;
|
||||
}
|
||||
if (part.type === "data-veilNotice") {
|
||||
// Only the first veilNotice in the whole conversation renders.
|
||||
if (message.id !== firstVeilMessageId) return null;
|
||||
return (
|
||||
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
||||
<ShieldCheck className="size-3" />
|
||||
@@ -669,6 +688,12 @@ export function ChatPanel() {
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Provenance footer: shown when the model cited records but placed no
|
||||
inline markers, so retrieved sources are always attributed. */}
|
||||
{sources.length > 0 && !hasInlineCitations && (
|
||||
<SourcesFooter sources={sources} />
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
@@ -685,7 +710,11 @@ export function ChatPanel() {
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
|
||||
<div className="relative flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex items-center px-4 pt-3">
|
||||
<ChatHistoryPanel />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4 py-8">
|
||||
<div className="flex w-full max-w-3xl shrink-0 flex-col items-center gap-10">
|
||||
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
|
||||
{t("chat.heading")}
|
||||
@@ -701,12 +730,16 @@ export function ChatPanel() {
|
||||
</Suggestions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center px-4 pt-3">
|
||||
<ChatHistoryPanel />
|
||||
</div>
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||
{messages.map((message, i) =>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Components } from "streamdown";
|
||||
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
PreviewCard,
|
||||
PreviewCardPopup,
|
||||
PreviewCardTrigger,
|
||||
} from "@/components/ui/preview-card";
|
||||
import type { SourceData } from "@/lib/ai-chat";
|
||||
|
||||
// The agent cites a retrieved record inline with a [[src:<id>]] marker. We turn
|
||||
// each marker into a hash link ([n](#cite-<id>)) — hash links survive
|
||||
// react-markdown's URL sanitization — then override the link renderer to show a
|
||||
// numbered inline citation chip whose hover card names the source.
|
||||
const CITATION_RE = /\[\[\s*src:\s*([a-zA-Z0-9_-]+)\s*\]\]/g;
|
||||
const CITE_HREF_PREFIX = "#cite-";
|
||||
|
||||
export function hasCitationMarkers(text: string): boolean {
|
||||
CITATION_RE.lastIndex = 0;
|
||||
return CITATION_RE.test(text);
|
||||
}
|
||||
|
||||
// Rewrites [[src:id]] → [n](#cite-id) for known sources; strips unknown markers.
|
||||
// Each source is cited at most once (its first occurrence) so an over-eager model
|
||||
// that tags every word with the same source collapses to a single chip.
|
||||
function withCitationLinks(
|
||||
text: string,
|
||||
numberById: Map<string, number>,
|
||||
): string {
|
||||
const seen = new Set<string>();
|
||||
return text.replace(CITATION_RE, (_match, id: string) => {
|
||||
const num = numberById.get(id);
|
||||
if (!num || seen.has(id)) return "";
|
||||
seen.add(id);
|
||||
return `[${num}](${CITE_HREF_PREFIX}${id})`;
|
||||
});
|
||||
}
|
||||
|
||||
function CitationChip({ num, source }: { num: number; source?: SourceData }) {
|
||||
const { t } = useTranslation();
|
||||
if (!source) return null;
|
||||
const kindKey = `chat.sources.kind.${source.kind}`;
|
||||
const kindLabel = t(kindKey);
|
||||
return (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={
|
||||
<Badge
|
||||
className="mx-0.5 cursor-default rounded-full px-1.5 align-super text-[10px] leading-none no-underline"
|
||||
variant="secondary"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{num}
|
||||
</PreviewCardTrigger>
|
||||
<PreviewCardPopup className="w-64 p-3">
|
||||
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{kindLabel === kindKey ? t("chat.sources.title") : kindLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 text-foreground text-sm">{source.title}</p>
|
||||
</PreviewCardPopup>
|
||||
</PreviewCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders assistant markdown with inline citation chips resolved from the
|
||||
// message's streamed sources.
|
||||
export function CitedResponse({
|
||||
text,
|
||||
sources,
|
||||
}: {
|
||||
text: string;
|
||||
sources: SourceData[];
|
||||
}) {
|
||||
const { numberById, sourceById, processed } = useMemo(() => {
|
||||
const numberById = new Map<string, number>();
|
||||
const sourceById = new Map<string, SourceData>();
|
||||
sources.forEach((s, i) => {
|
||||
numberById.set(s.id, i + 1);
|
||||
sourceById.set(s.id, s);
|
||||
});
|
||||
return {
|
||||
numberById,
|
||||
sourceById,
|
||||
processed: withCitationLinks(text, numberById),
|
||||
};
|
||||
}, [text, sources]);
|
||||
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
a({ href, children, ...props }) {
|
||||
if (typeof href === "string" && href.startsWith(CITE_HREF_PREFIX)) {
|
||||
const id = href.slice(CITE_HREF_PREFIX.length);
|
||||
const num = numberById.get(id) ?? 0;
|
||||
return <CitationChip num={num} source={sourceById.get(id)} />;
|
||||
}
|
||||
return (
|
||||
<a href={href} rel="noreferrer" target="_blank" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[numberById, sourceById],
|
||||
);
|
||||
|
||||
return <MessageResponse components={components}>{processed}</MessageResponse>;
|
||||
}
|
||||
|
||||
// Provenance footer shown beneath a message that has sources — primarily a
|
||||
// fallback when the model didn't place inline markers, so retrieved records are
|
||||
// always attributed.
|
||||
export function SourcesFooter({ sources }: { sources: SourceData[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 border-border/60 border-t pt-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.sources.title")}
|
||||
</span>
|
||||
{sources.map((s, i) => (
|
||||
<Badge className="gap-1 rounded-full font-normal" key={s.id} variant="outline">
|
||||
<span className="text-muted-foreground">{i + 1}</span>
|
||||
<span className="max-w-48 truncate">{s.title}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { ArrowRight, Pencil } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -61,9 +61,14 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
};
|
||||
|
||||
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
|
||||
// plus a subtle clickable affordance (they open a detail dialog).
|
||||
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
|
||||
// size to their own (short) content — see `items-start` in PatientResult.
|
||||
const rowCard =
|
||||
"w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
// Same footprint as `rowCard` but with no clickable affordance — used when a
|
||||
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
|
||||
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
|
||||
|
||||
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||
@@ -100,30 +105,6 @@ function Empty({ children }: { children: ReactNode }) {
|
||||
return <p className="text-muted-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
function TrendBlock({ trend }: { trend: Trend }) {
|
||||
const { t } = useTranslation();
|
||||
if (trend.points.length === 0) {
|
||||
return <Empty>{t("patientCard.trend.empty")}</Empty>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<SectionLabel>
|
||||
{t("patientCard.trend.last", {
|
||||
label: trend.label,
|
||||
count: trend.points.length,
|
||||
})}
|
||||
</SectionLabel>
|
||||
<span className="text-foreground">
|
||||
{trend.points.at(-1)}
|
||||
<span className="text-muted-foreground"> {trend.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline points={trend.points} unit={trend.unit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendDetail({ trend }: { trend: Trend }) {
|
||||
const { t } = useTranslation();
|
||||
if (trend.points.length === 0) {
|
||||
@@ -177,18 +158,28 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// A card that previews `children` and opens a roomier dialog of `detail` on click.
|
||||
// A compact card that previews `children` and opens a roomier dialog of `detail`
|
||||
// on click. A muted "Click for more" footer signals the card is expandable.
|
||||
// When `expandable` is false (the card holds nothing beyond its preview), it
|
||||
// renders as a plain, non-clickable card with no footer — so empty sections
|
||||
// don't misleadingly promise more.
|
||||
function ExpandableCard({
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
children,
|
||||
expandable = true,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
detail: ReactNode;
|
||||
children: ReactNode;
|
||||
expandable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!expandable) {
|
||||
return <Card className={cn(rowCardStatic, compactCard)}>{children}</Card>;
|
||||
}
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
@@ -196,6 +187,10 @@ function ExpandableCard({
|
||||
render={<Card className={cn(rowCard, compactCard)} />}
|
||||
>
|
||||
{children}
|
||||
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
|
||||
{t("patientCard.clickForMore")}
|
||||
<ArrowRight className="size-3" />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@@ -243,6 +238,16 @@ function SummaryCard({
|
||||
/>
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
{onEdit ? (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 font-medium text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.summary.editRecord")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
title={patient.name}
|
||||
@@ -259,34 +264,15 @@ function SummaryCard({
|
||||
<Badge variant={statusVariant[patient.status]}>{statusLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
|
||||
<Stat
|
||||
label={t("patientCard.summary.lastSeen")}
|
||||
value={patient.encounters[0]?.date ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.activeMeds")}
|
||||
value={patient.medications.length}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.openProblems")}
|
||||
value={patient.problems.length}
|
||||
/>
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
<button
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.summary.editRecord")}
|
||||
</button>
|
||||
<CardContent className="grid grid-cols-2 gap-x-3 gap-y-2 pt-0">
|
||||
<Stat
|
||||
label={t("patientCard.summary.activeMeds")}
|
||||
value={patient.medications.length}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.openProblems")}
|
||||
value={patient.problems.length}
|
||||
/>
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
@@ -309,9 +295,18 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasVitals = Boolean(
|
||||
vitals.bp ||
|
||||
vitals.hr ||
|
||||
vitals.temp ||
|
||||
vitals.spo2 ||
|
||||
patient.vitalsTrend.points.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
|
||||
expandable={hasVitals}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
@@ -327,10 +322,9 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
{t("patientCard.vitals.taken", { at: vitals.takenAt })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.vitalsTrend} />
|
||||
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-2 pt-0">
|
||||
<Stat label={t("patientCard.vitals.bp")} value={vitals.bp} />
|
||||
<Stat label={t("patientCard.vitals.hr")} value={vitals.hr} />
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
@@ -353,6 +347,7 @@ function LabsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.labs.asOf", {
|
||||
at: patient.labs[0]?.takenAt ?? "—",
|
||||
})}
|
||||
expandable={patient.labs.length > 0}
|
||||
detail={
|
||||
patient.labs.length === 0 ? (
|
||||
<Empty>{t("patientCard.labs.empty")}</Empty>
|
||||
@@ -387,25 +382,6 @@ function LabsCard({ patient }: { patient: Patient }) {
|
||||
{t("patientCard.labs.asOf", { at: patient.labs[0]?.takenAt ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{patient.labs.length === 0 ? (
|
||||
<Empty>{t("patientCard.labs.empty")}</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.labs.map((lab) => (
|
||||
<Row
|
||||
key={lab.name}
|
||||
label={lab.name}
|
||||
value={<LabValue flag={lab.flag} value={lab.value} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.labTrend} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -431,6 +407,7 @@ function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.medications.active", {
|
||||
count: patient.medications.length,
|
||||
})}
|
||||
expandable={patient.medications.length > 0}
|
||||
detail={list}
|
||||
title={t("patientCard.medications.title")}
|
||||
>
|
||||
@@ -442,7 +419,6 @@ function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -468,6 +444,7 @@ function ProblemsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.problems.active", {
|
||||
count: patient.problems.length,
|
||||
})}
|
||||
expandable={patient.problems.length > 0}
|
||||
detail={list}
|
||||
title={t("patientCard.problems.title")}
|
||||
>
|
||||
@@ -477,7 +454,6 @@ function ProblemsCard({ patient }: { patient: Patient }) {
|
||||
{t("patientCard.problems.active", { count: patient.problems.length })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -524,14 +500,24 @@ function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<ExpandableCard
|
||||
detail={<AllergiesList patient={patient} />}
|
||||
expandable={patient.allergies.length > 0 || patient.alerts.length > 0}
|
||||
title={t("patientCard.allergies.title")}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("patientCard.allergies.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
{patient.allergies.length === 0
|
||||
? t("patientCard.allergies.none")
|
||||
: t("patientCard.allergies.count", {
|
||||
count: patient.allergies.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AllergiesList patient={patient} />
|
||||
</CardContent>
|
||||
{patient.alerts.length > 0 ? (
|
||||
<CardContent className="pt-0">
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
</CardContent>
|
||||
) : null}
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
@@ -571,15 +557,16 @@ function VisitsCard({ patient }: { patient: Patient }) {
|
||||
description={t("patientCard.visits.recent", {
|
||||
count: patient.encounters.length,
|
||||
})}
|
||||
expandable={patient.encounters.length > 0}
|
||||
detail={<VisitsList patient={patient} />}
|
||||
title={t("patientCard.visits.title")}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("patientCard.visits.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("patientCard.visits.recent", { count: patient.encounters.length })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VisitsList patient={patient} />
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,22 @@ export function CreateClinicForm({
|
||||
return;
|
||||
}
|
||||
|
||||
await authClient.organization.setActive({ organizationId: org.id });
|
||||
const { error: activeErr } = await authClient.organization.setActive({
|
||||
organizationId: org.id,
|
||||
});
|
||||
if (activeErr) {
|
||||
const message = activeErr.message ?? t("clinic.createError");
|
||||
setError(message);
|
||||
notify.error(t("clinic.createFailedTitle"), message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the cached session so `activeOrganizationId` is populated before we
|
||||
// navigate — otherwise AppAuthGuard still sees no active clinic and bounces
|
||||
// straight back to /onboarding.
|
||||
await authClient.getSession({ query: { disableCookieCache: true } });
|
||||
|
||||
notify.success(t("clinic.createdTitle"), t("clinic.createdBody", { name: org.name }));
|
||||
onCreated?.(org);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
// d3-force simulation computed once; rendering + pan/zoom is React Flow. Nodes
|
||||
// are round "dots" with the label underneath, and hovering a node highlights it
|
||||
// and its neighbours while dimming the rest (the Obsidian focus effect).
|
||||
//
|
||||
// Hover is driven through React context — NOT through the `nodes`/`edges` props
|
||||
// — so the arrays React Flow receives stay referentially stable. That avoids the
|
||||
// re-measure/flicker loop you get when hover state recreates the node objects
|
||||
// (and the hovered node's transform shifts it out from under the cursor).
|
||||
|
||||
import {
|
||||
Background,
|
||||
BaseEdge,
|
||||
Controls,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
getStraightPath,
|
||||
Handle,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
@@ -27,7 +35,8 @@ import {
|
||||
type SimulationLinkDatum,
|
||||
type SimulationNodeDatum,
|
||||
} from "d3-force";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { createContext, memo, useContext, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Patient } from "@/lib/patients";
|
||||
@@ -47,11 +56,15 @@ type RecordNodeData = {
|
||||
label: string;
|
||||
sub?: string;
|
||||
kind: Kind;
|
||||
// Hover focus: "active" = this node or a neighbour, "dim" = unrelated, null =
|
||||
// nothing hovered (everything at full strength).
|
||||
focus: "active" | "dim" | null;
|
||||
// Ids of this node plus its direct neighbours — used to derive hover focus
|
||||
// from the hovered id without recreating the node objects.
|
||||
family: string[];
|
||||
};
|
||||
|
||||
// The id of the node currently hovered (or null). Read by nodes + edges so the
|
||||
// hover highlight never touches the React Flow `nodes`/`edges` props.
|
||||
const HoverContext = createContext<string | null>(null);
|
||||
|
||||
// Dot size + colour per kind. The patient is the biggest, brightest hub.
|
||||
const dotClass: Record<Kind, string> = {
|
||||
patient: "size-5 bg-primary shadow-[0_0_16px_2px] shadow-primary/40",
|
||||
@@ -60,9 +73,14 @@ const dotClass: Record<Kind, string> = {
|
||||
};
|
||||
|
||||
// A round node with the label below it and hidden connection handles so edges
|
||||
// meet the dot's centre cleanly.
|
||||
function RecordNode({ data }: NodeProps) {
|
||||
const { label, sub, kind, focus } = data as RecordNodeData;
|
||||
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
|
||||
// id changes (via context), never via React Flow re-measuring.
|
||||
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
|
||||
const { label, sub, kind, family } = data as RecordNodeData;
|
||||
const hovered = useContext(HoverContext);
|
||||
const focus: "active" | "dim" | null =
|
||||
hovered == null ? null : family.includes(hovered) ? "active" : "dim";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -72,9 +90,9 @@ function RecordNode({ data }: NodeProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full ring-1 ring-background/60 transition-transform duration-200",
|
||||
"rounded-full ring-1 ring-background/60 transition-shadow duration-200",
|
||||
dotClass[kind],
|
||||
focus === "active" && "scale-125",
|
||||
focus === "active" && "ring-2 ring-foreground/40",
|
||||
)}
|
||||
>
|
||||
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||
@@ -103,9 +121,36 @@ function RecordNode({ data }: NodeProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A straight edge that brightens when it touches the hovered node and dims when
|
||||
// it doesn't. Reads the hovered id from context so the `edges` prop stays stable.
|
||||
const RecordEdge = memo(function RecordEdge({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
source,
|
||||
target,
|
||||
}: EdgeProps) {
|
||||
const hovered = useContext(HoverContext);
|
||||
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY });
|
||||
const touches = !hovered || source === hovered || target === hovered;
|
||||
return (
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{
|
||||
stroke: hovered && touches ? "var(--primary)" : "var(--border)",
|
||||
strokeWidth: hovered && touches ? 1.75 : 1.25,
|
||||
opacity: hovered && !touches ? 0.12 : 0.7,
|
||||
transition: "stroke 0.2s, opacity 0.2s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const nodeTypes = { record: RecordNode };
|
||||
const edgeTypes = { record: RecordEdge };
|
||||
|
||||
// Build nodes + edges from the record. A visit links to a problem when its
|
||||
// type/summary mentions the problem label (case-insensitive) — that produces
|
||||
@@ -172,27 +217,24 @@ export function RecordGraph({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The node the pointer is over; drives the focus highlight.
|
||||
const [hover, setHover] = useState<string | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
// The hovered node id — drives the focus highlight via context only.
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
// Stable base layout (positions + adjacency), computed once per patient.
|
||||
const base = useMemo(() => {
|
||||
// Nodes + edges are computed once per patient and stay referentially stable
|
||||
// across hover, so React Flow never re-measures (the source of the flicker).
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const g = buildGraph(patient);
|
||||
layout(g.nodes, g.edges);
|
||||
// Adjacency for the hover focus: a node is "active" when it is hovered or
|
||||
// directly linked to the hovered node.
|
||||
const neighbours = new Map<string, Set<string>>();
|
||||
for (const n of g.nodes) neighbours.set(n.id, new Set([n.id]));
|
||||
// For each node, the set of ids that should stay lit when it is hovered:
|
||||
// itself plus its direct neighbours.
|
||||
const family = new Map<string, Set<string>>();
|
||||
for (const n of g.nodes) family.set(n.id, new Set([n.id]));
|
||||
for (const e of g.edges) {
|
||||
neighbours.get(e.source)?.add(e.target);
|
||||
neighbours.get(e.target)?.add(e.source);
|
||||
family.get(e.source)?.add(e.target);
|
||||
family.get(e.target)?.add(e.source);
|
||||
}
|
||||
return { nodes: g.nodes, edges: g.edges, neighbours };
|
||||
}, [patient]);
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
const active = hover ? base.neighbours.get(hover) : null;
|
||||
return base.nodes.map((n) => ({
|
||||
const rfNodes: Node[] = g.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "record",
|
||||
position: { x: n.x ?? 0, y: n.y ?? 0 },
|
||||
@@ -200,27 +242,17 @@ export function RecordGraph({
|
||||
label: n.label,
|
||||
sub: n.sub,
|
||||
kind: n.kind,
|
||||
focus: active ? (active.has(n.id) ? "active" : "dim") : null,
|
||||
family: Array.from(family.get(n.id) ?? [n.id]),
|
||||
} satisfies RecordNodeData,
|
||||
}));
|
||||
}, [base, hover]);
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
return base.edges.map((e) => {
|
||||
const touches = !hover || e.source === hover || e.target === hover;
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: {
|
||||
stroke: touches && hover ? "var(--primary)" : "var(--border)",
|
||||
strokeWidth: touches && hover ? 1.75 : 1.25,
|
||||
opacity: hover && !touches ? 0.12 : 0.7,
|
||||
transition: "stroke 0.2s, opacity 0.2s",
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [base, hover]);
|
||||
const rfEdges: Edge[] = g.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: "record",
|
||||
}));
|
||||
return { nodes: rfNodes, edges: rfEdges };
|
||||
}, [patient]);
|
||||
|
||||
if (patient.problems.length === 0 && patient.encounters.length === 0) {
|
||||
return (
|
||||
@@ -237,23 +269,27 @@ export function RecordGraph({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactFlow
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable={false}
|
||||
onNodeMouseEnter={(_, node) => setHover(node.id)}
|
||||
onNodeMouseLeave={() => setHover(null)}
|
||||
panOnScroll
|
||||
proOptions={{ hideAttribution: true }}
|
||||
zoomOnScroll={false}
|
||||
>
|
||||
<Background color="var(--border)" gap={22} size={1.5} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
<HoverContext.Provider value={hovered}>
|
||||
<ReactFlow
|
||||
colorMode={resolvedTheme === "light" ? "light" : "dark"}
|
||||
edgeTypes={edgeTypes}
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable={false}
|
||||
onNodeMouseEnter={(_, node) => setHovered(node.id)}
|
||||
onNodeMouseLeave={() => setHovered(null)}
|
||||
panOnScroll
|
||||
proOptions={{ hideAttribution: true }}
|
||||
zoomOnScroll={false}
|
||||
>
|
||||
<Background color="var(--border)" gap={22} size={1.5} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</HoverContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Mic,
|
||||
MicOff,
|
||||
MonitorUp,
|
||||
PhoneOff,
|
||||
Search,
|
||||
UserPlus,
|
||||
Video as VideoIcon,
|
||||
VideoOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSpeaking } from "@/components/meetings/use-audio-level";
|
||||
import { useWebRtcMesh } from "@/components/meetings/use-webrtc-mesh";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipPopup,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { listClinicMembers, type Participant } from "@/lib/messages";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
// One participant tile: video when the camera is on, an avatar otherwise, with a
|
||||
// green speaking ring driven by the stream's audio level.
|
||||
function VideoTile({
|
||||
stream,
|
||||
label,
|
||||
caption,
|
||||
muted,
|
||||
showVideo,
|
||||
}: {
|
||||
stream: MediaStream | null;
|
||||
label: string;
|
||||
// The corner caption (e.g. "You"); falls back to `label` when omitted. Initials
|
||||
// are always derived from `label` (the real name), never the caption.
|
||||
caption?: string;
|
||||
muted?: boolean;
|
||||
showVideo: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
// Analyse the stream's audio for the speaking ring (muting only affects
|
||||
// playback, so the local tile still gets a ring when you talk).
|
||||
const speaking = useSpeaking(stream);
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.srcObject !== stream) {
|
||||
ref.current.srcObject = stream;
|
||||
}
|
||||
}, [stream]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-video overflow-hidden rounded-2xl border-2 bg-muted transition-colors",
|
||||
speaking ? "border-success" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
autoPlay
|
||||
className={cn("size-full object-cover", !showVideo && "invisible")}
|
||||
muted={muted}
|
||||
playsInline
|
||||
ref={ref}
|
||||
/>
|
||||
{!showVideo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
<Avatar className="size-16">
|
||||
<AvatarFallback className="bg-primary/15 text-foreground text-lg">
|
||||
{initials(label)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute bottom-2 left-2 rounded-full bg-background/70 px-2 py-0.5 text-foreground text-xs backdrop-blur">
|
||||
{caption ?? label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A round control-bar button with a tooltip label.
|
||||
function ControlButton({
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
variant = "secondary",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
variant?: "secondary" | "outline" | "default" | "destructive";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-14 rounded-full"
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
variant={active === false ? "outline" : variant}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipPopup>{label}</TooltipPopup>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeetingRoom({
|
||||
roomId,
|
||||
roomName,
|
||||
selfName,
|
||||
onLeave,
|
||||
}: {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
selfName: string;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
const {
|
||||
localStream,
|
||||
peers,
|
||||
joinState,
|
||||
micOn,
|
||||
camOn,
|
||||
screenOn,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
toggleScreen,
|
||||
maxPeers,
|
||||
} = useWebRtcMesh(roomId);
|
||||
|
||||
// Invite picker.
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
const [invited, setInvited] = useState<Set<string>>(new Set());
|
||||
|
||||
const openInvite = () => {
|
||||
setInviteOpen(true);
|
||||
setMemberQuery("");
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
};
|
||||
|
||||
const invite = (userId: string) => {
|
||||
getSocket().emit("call:invite", { roomId, toUserId: userId });
|
||||
setInvited((prev) => new Set(prev).add(userId));
|
||||
};
|
||||
|
||||
const visibleMembers = members.filter(
|
||||
(m) =>
|
||||
m.id !== myId &&
|
||||
m.name.toLowerCase().includes(memberQuery.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border bg-card/30 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{roomName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{joinState === "joined"
|
||||
? t("meetings.inCall", { count: peers.length + 1 })
|
||||
: joinState === "joining"
|
||||
? t("meetings.connecting")
|
||||
: joinState === "full"
|
||||
? t("meetings.roomFull", { max: maxPeers })
|
||||
: joinState === "error"
|
||||
? t("meetings.callError")
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
|
||||
{joinState === "full" || joinState === "error" ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground text-sm">
|
||||
{joinState === "full"
|
||||
? t("meetings.roomFull", { max: maxPeers })
|
||||
: t("meetings.callError")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<VideoTile
|
||||
caption={t("meetings.you")}
|
||||
label={selfName || t("meetings.you")}
|
||||
muted
|
||||
showVideo={camOn && Boolean(localStream)}
|
||||
stream={localStream}
|
||||
/>
|
||||
{peers.map((p) => (
|
||||
<VideoTile
|
||||
key={p.socketId}
|
||||
label={p.userName || t("meetings.guest")}
|
||||
showVideo={Boolean(p.stream)}
|
||||
stream={p.stream}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Discord-style control bar — a compact pill that hugs its buttons */}
|
||||
<div className="mx-auto flex w-fit items-center gap-2 rounded-full border bg-card/60 px-3 py-2 backdrop-blur">
|
||||
<ControlButton
|
||||
active={micOn}
|
||||
label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
|
||||
onClick={toggleMic}
|
||||
>
|
||||
{micOn ? <Mic className="size-6" /> : <MicOff className="size-6" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
active={camOn}
|
||||
label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
|
||||
onClick={toggleCam}
|
||||
>
|
||||
{camOn ? (
|
||||
<VideoIcon className="size-6" />
|
||||
) : (
|
||||
<VideoOff className="size-6" />
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
label={t("meetings.shareScreen")}
|
||||
onClick={() => void toggleScreen()}
|
||||
variant={screenOn ? "default" : "secondary"}
|
||||
>
|
||||
<MonitorUp className="size-6" />
|
||||
</ControlButton>
|
||||
<ControlButton label={t("meetings.invite.add")} onClick={openInvite}>
|
||||
<UserPlus className="size-6" />
|
||||
</ControlButton>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<ControlButton
|
||||
label={t("meetings.leave")}
|
||||
onClick={onLeave}
|
||||
variant="destructive"
|
||||
>
|
||||
<PhoneOff className="size-6" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
|
||||
<Dialog onOpenChange={setInviteOpen} open={inviteOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.invite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("meetings.invite.description", { room: roomName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("meetings.invite.search")}
|
||||
className="pl-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("meetings.invite.search")}
|
||||
size="sm"
|
||||
value={memberQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{visibleMembers.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.invite.noMembers")}
|
||||
</p>
|
||||
) : (
|
||||
visibleMembers.map((m) => (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2"
|
||||
key={m.id}
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials(m.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
<Button
|
||||
disabled={invited.has(m.id)}
|
||||
onClick={() => invite(m.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{invited.has(m.id)
|
||||
? t("meetings.invite.invited")
|
||||
: t("meetings.invite.ring")}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarDays, Plus, Trash2, Users, Video } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MeetingRoom } from "@/components/meetings/meeting-room";
|
||||
import { ScheduleMeetingDialog } from "@/components/meetings/schedule-meeting-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
createMeetingRoom,
|
||||
deleteMeetingRoom,
|
||||
listMeetingEvents,
|
||||
listMeetingRooms,
|
||||
type MeetingRoom as Room,
|
||||
type ScheduledMeeting,
|
||||
} from "@/lib/meetings";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tab = "rooms" | "calendar";
|
||||
|
||||
// Local YYYY-MM-DD key for a Date (no UTC drift), matching how meetings store date.
|
||||
function keyOf(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function MeetingsView() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const selfName = session?.user?.name ?? "";
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const deepLinkRoom = searchParams.get("room");
|
||||
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
|
||||
// pre-targeted at that person so the user can connect with them.
|
||||
const deepLinkWith = searchParams.get("with");
|
||||
const openedDeepLink = useRef<string | null>(null);
|
||||
const openedWith = useRef<string | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<Tab>("rooms");
|
||||
|
||||
// Rooms / live calls.
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [activeRoom, setActiveRoom] = useState<Room | null>(null);
|
||||
const [presence, setPresence] = useState<Record<string, number>>({});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [roomToDelete, setRoomToDelete] = useState<Room | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Scheduled meetings (calendar).
|
||||
const [events, setEvents] = useState<ScheduledMeeting[]>([]);
|
||||
const [selectedDay, setSelectedDay] = useState<Date>(new Date());
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listMeetingRooms()
|
||||
.then(setRooms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadEvents = () => {
|
||||
listMeetingEvents()
|
||||
.then(setEvents)
|
||||
.catch(() => {});
|
||||
};
|
||||
useEffect(loadEvents, []);
|
||||
|
||||
// Live room occupancy.
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onPresence = ({ roomId, count }: { roomId: string; count: number }) => {
|
||||
setPresence((prev) => ({ ...prev, [roomId]: count }));
|
||||
};
|
||||
socket.on("call:presence", onPresence);
|
||||
return () => {
|
||||
socket.off("call:presence", onPresence);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-join a room deep-linked from an invite (?room=).
|
||||
useEffect(() => {
|
||||
if (!deepLinkRoom || rooms.length === 0) return;
|
||||
if (openedDeepLink.current === deepLinkRoom) return;
|
||||
const room = rooms.find((r) => r.id === deepLinkRoom);
|
||||
if (!room) return;
|
||||
openedDeepLink.current = deepLinkRoom;
|
||||
setTab("rooms");
|
||||
setActiveRoom(room);
|
||||
}, [deepLinkRoom, rooms]);
|
||||
|
||||
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
|
||||
useEffect(() => {
|
||||
if (!deepLinkWith || openedWith.current === deepLinkWith) return;
|
||||
openedWith.current = deepLinkWith;
|
||||
setTab("calendar");
|
||||
setScheduleOpen(true);
|
||||
}, [deepLinkWith]);
|
||||
|
||||
const createRoom = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name || creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const room = await createMeetingRoom(name);
|
||||
setRooms((prev) => [...prev, room]);
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
setActiveRoom(room);
|
||||
} catch {
|
||||
notify.error(t("meetings.createFailedTitle"), t("meetings.createFailedBody"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteRoom = async () => {
|
||||
if (!roomToDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteMeetingRoom(roomToDelete.id);
|
||||
setRooms((prev) => prev.filter((r) => r.id !== roomToDelete.id));
|
||||
if (activeRoom?.id === roomToDelete.id) setActiveRoom(null);
|
||||
setRoomToDelete(null);
|
||||
} catch {
|
||||
notify.error(t("meetings.deleteFailedTitle"), t("meetings.deleteFailedBody"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const meetingDays = useMemo(
|
||||
() => events.map((e) => new Date(`${e.date}T00:00:00`)),
|
||||
[events],
|
||||
);
|
||||
const dayEvents = useMemo(
|
||||
() =>
|
||||
events
|
||||
.filter((e) => e.date === keyOf(selectedDay))
|
||||
.sort((a, b) => a.time.localeCompare(b.time)),
|
||||
[events, selectedDay],
|
||||
);
|
||||
|
||||
// Midnight today — used to disable past calendar dates and filter "upcoming".
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
// Next few meetings from today onward, soonest first.
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date();
|
||||
return events
|
||||
.filter((e) => new Date(`${e.date}T${e.time}`) >= now)
|
||||
.sort((a, b) =>
|
||||
`${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`),
|
||||
)
|
||||
.slice(0, 4);
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||
{/* Header: Rooms / Calendar tabs */}
|
||||
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 self-start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
|
||||
tab === "rooms"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab("rooms")}
|
||||
type="button"
|
||||
>
|
||||
<Video className="size-4" />
|
||||
{t("meetings.rooms")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-sm transition-colors",
|
||||
tab === "calendar"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab("calendar")}
|
||||
type="button"
|
||||
>
|
||||
<CalendarDays className="size-4" />
|
||||
{t("meetings.calendar")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "rooms" ? (
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
{/* Room (channel) list */}
|
||||
<aside className="flex w-64 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-base tracking-tight">
|
||||
{t("meetings.rooms")}
|
||||
</h1>
|
||||
<Button
|
||||
aria-label={t("meetings.newRoom")}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{rooms.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
||||
{t("meetings.noRooms")}
|
||||
</p>
|
||||
) : (
|
||||
rooms.map((room) => {
|
||||
const count = presence[room.id] ?? 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-accent/50",
|
||||
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={room.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-left"
|
||||
onClick={() => setActiveRoom(room)}
|
||||
type="button"
|
||||
>
|
||||
<Video className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{room.name}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-success/15 px-1.5 text-success text-xs">
|
||||
<Users className="size-3" />
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t("meetings.deleteRoom")}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => setRoomToDelete(room)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Live call or lobby */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeRoom ? (
|
||||
<MeetingRoom
|
||||
key={activeRoom.id}
|
||||
onLeave={() => setActiveRoom(null)}
|
||||
roomId={activeRoom.id}
|
||||
roomName={activeRoom.name}
|
||||
selfName={selfName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Video />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("meetings.emptyTitle")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t("meetings.emptyDescription")}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("meetings.newRoom")}
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Calendar tab
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border bg-card/30 p-3">
|
||||
<Calendar
|
||||
disabled={{ before: today }}
|
||||
mode="single"
|
||||
modifiers={{ hasMeeting: meetingDays }}
|
||||
modifiersClassNames={{
|
||||
hasMeeting:
|
||||
"relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-primary",
|
||||
}}
|
||||
onSelect={(d) => d && setSelectedDay(d)}
|
||||
required
|
||||
selected={selectedDay}
|
||||
/>
|
||||
<Button onClick={() => setScheduleOpen(true)} type="button">
|
||||
<Plus className="size-4" />
|
||||
{t("meetings.schedule.cta")}
|
||||
</Button>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-1.5">
|
||||
<span className="px-1 font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("meetings.upcoming.title")}
|
||||
</span>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="px-1 py-2 text-muted-foreground text-xs">
|
||||
{t("meetings.upcoming.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{upcoming.map((e) => (
|
||||
<button
|
||||
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
key={e.id}
|
||||
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{e.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{new Date(`${e.date}T00:00:00`).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric" },
|
||||
)}{" "}
|
||||
· {e.time}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||
<div className="border-border border-b px-4 py-3">
|
||||
<h2 className="font-semibold text-base tracking-tight">
|
||||
{selectedDay.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
|
||||
{dayEvents.length === 0 ? (
|
||||
<Empty className="border-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CalendarDays />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("meetings.calendarEmpty")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t("meetings.calendarEmptyHint")}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
onClick={() => setScheduleOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("meetings.schedule.cta")}
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-xl border bg-card px-3 py-2.5"
|
||||
key={e.id}
|
||||
>
|
||||
<span className="font-medium text-foreground text-sm tabular-nums">
|
||||
{e.time}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{e.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{e.participantNames.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<Avatar className="size-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{e.participantNames.length}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create room dialog */}
|
||||
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.newRoom")}</DialogTitle>
|
||||
<DialogDescription>{t("meetings.newRoomDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="contents" onSubmit={createRoom}>
|
||||
<DialogPanel>
|
||||
<Input
|
||||
aria-label={t("meetings.roomNameLabel")}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("meetings.roomNamePlaceholder")}
|
||||
value={newName}
|
||||
/>
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("meetings.cancel")}
|
||||
</DialogClose>
|
||||
<Button disabled={!newName.trim() || creating} type="submit">
|
||||
{creating ? t("meetings.creating") : t("meetings.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(open) => !open && setRoomToDelete(null)}
|
||||
open={roomToDelete !== null}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.deleteRoomConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("meetings.deleteRoomConfirmBody", {
|
||||
name: roomToDelete?.name ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("meetings.cancel")}
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={deleting}
|
||||
onClick={confirmDeleteRoom}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{deleting ? t("meetings.deleting") : t("meetings.deleteRoom")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
<ScheduleMeetingDialog
|
||||
defaultDate={keyOf(selectedDay)}
|
||||
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
|
||||
onCreated={loadEvents}
|
||||
onOpenChange={setScheduleOpen}
|
||||
open={scheduleOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { createMeetingEvent } from "@/lib/meetings";
|
||||
import { listClinicMembers, type Participant } from "@/lib/messages";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
export function ScheduleMeetingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultDate,
|
||||
defaultParticipants,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultDate?: string; // YYYY-MM-DD
|
||||
defaultParticipants?: string[]; // member ids to preselect
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [date, setDate] = useState(defaultDate ?? "");
|
||||
const [time, setTime] = useState("09:00");
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle("");
|
||||
setDate(defaultDate ?? "");
|
||||
setTime("09:00");
|
||||
setPicked(new Set(defaultParticipants ?? []));
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
}, [open, defaultDate, defaultParticipants]);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!(title.trim() && date && time) || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await createMeetingEvent({
|
||||
title: title.trim(),
|
||||
date,
|
||||
time,
|
||||
participants: [...picked],
|
||||
});
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("meetings.schedule.failedTitle"),
|
||||
t("meetings.schedule.failedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("meetings.schedule.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("meetings.schedule.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-title">
|
||||
{t("meetings.schedule.titleLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-title"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("meetings.schedule.titlePlaceholder")}
|
||||
value={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-date">
|
||||
{t("meetings.schedule.date")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-date"
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
type="date"
|
||||
value={date}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="font-medium text-sm" htmlFor="meeting-time">
|
||||
{t("meetings.schedule.time")}
|
||||
</label>
|
||||
<Input
|
||||
id="meeting-time"
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
type="time"
|
||||
value={time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-medium text-sm">
|
||||
{t("meetings.schedule.participants")}
|
||||
</span>
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-2xl border p-1">
|
||||
{members.filter((m) => m.id !== myId).length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-muted-foreground text-sm">
|
||||
{t("meetings.invite.noMembers")}
|
||||
</p>
|
||||
) : (
|
||||
members
|
||||
.filter((m) => m.id !== myId)
|
||||
.map((m) => {
|
||||
const on = picked.has(m.id);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
|
||||
on && "bg-accent",
|
||||
)}
|
||||
key={m.id}
|
||||
onClick={() => toggle(m.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{initials(m.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
{on && <Check className="size-4 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("meetings.cancel")}
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={!(title.trim() && date && time) || saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? t("meetings.schedule.saving") : t("meetings.schedule.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Returns true while the given stream's audio is above a speaking threshold —
|
||||
// used to draw a Discord-style "speaking" ring around a participant's tile.
|
||||
// Degrades silently if the Web Audio API isn't available.
|
||||
export function useSpeaking(stream: MediaStream | null): boolean {
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || stream.getAudioTracks().length === 0) {
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
let ctx: AudioContext | null = null;
|
||||
let raf = 0;
|
||||
try {
|
||||
ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i]! - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
setSpeaking(Math.sqrt(sum / data.length) > 0.045);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
} catch {
|
||||
/* no Web Audio — no ring */
|
||||
}
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ctx?.close().catch(() => {});
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
return speaking;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { toastManager } from "@/components/ui/toast";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
|
||||
type CallInvite = { roomId: string; roomName: string; fromName: string };
|
||||
|
||||
// Listens for live "call:invite" events and shows a toast with a Join action
|
||||
// that drops the user into the room. Mounted once in the app shell (the sidebar)
|
||||
// so it works from any page.
|
||||
export function useCallInvites() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
|
||||
toastManager.add({
|
||||
type: "info",
|
||||
// Ring long enough for the callee to react; the toast's "x" declines it.
|
||||
timeout: 30_000,
|
||||
title: t("meetings.invite.toastTitle", { name: fromName }),
|
||||
description: roomName,
|
||||
actionProps: {
|
||||
// "Accept" drops the user straight into the caller's room.
|
||||
children: t("meetings.invite.accept"),
|
||||
onClick: () =>
|
||||
router.push(
|
||||
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
socket.on("call:invite", onInvite);
|
||||
return () => {
|
||||
socket.off("call:invite", onInvite);
|
||||
};
|
||||
}, [router, t]);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { type CallPeer, MAX_CALL_PEERS } from "@/lib/meetings";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
|
||||
// Public STUN keeps this dependency-free for clinics on the same network or with
|
||||
// simple NATs. Cross-network/symmetric-NAT calls would also need a TURN server —
|
||||
// a deployment add-on, out of scope for the mesh MVP.
|
||||
const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
];
|
||||
|
||||
export type RemotePeer = {
|
||||
socketId: string;
|
||||
userName: string;
|
||||
stream: MediaStream | null;
|
||||
};
|
||||
|
||||
export type JoinState = "idle" | "joining" | "joined" | "full" | "error";
|
||||
|
||||
type Signal = {
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
candidate?: RTCIceCandidateInit;
|
||||
};
|
||||
|
||||
// A peer-to-peer mesh call over the shared Socket.io connection: each participant
|
||||
// holds one RTCPeerConnection per other participant. The newcomer initiates
|
||||
// offers to everyone already in the room; existing peers answer. Media never
|
||||
// touches the server — it only relays SDP/ICE.
|
||||
export function useWebRtcMesh(roomId: string | null) {
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [peers, setPeers] = useState<RemotePeer[]>([]);
|
||||
const [joinState, setJoinState] = useState<JoinState>("idle");
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [screenOn, setScreenOn] = useState(false);
|
||||
|
||||
const pcsRef = useRef(new Map<string, RTCPeerConnection>());
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const cameraTrackRef = useRef<MediaStreamTrack | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const upsertPeer = useCallback((peer: Partial<RemotePeer> & { socketId: string }) => {
|
||||
setPeers((prev) => {
|
||||
const existing = prev.find((p) => p.socketId === peer.socketId);
|
||||
if (existing) {
|
||||
return prev.map((p) =>
|
||||
p.socketId === peer.socketId ? { ...p, ...peer } : p,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ socketId: peer.socketId, userName: peer.userName ?? "", stream: peer.stream ?? null },
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePeer = useCallback((socketId: string) => {
|
||||
pcsRef.current.get(socketId)?.close();
|
||||
pcsRef.current.delete(socketId);
|
||||
setPeers((prev) => prev.filter((p) => p.socketId !== socketId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
const socket = getSocket();
|
||||
const pcs = pcsRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
const createPc = (peerId: string, userName: string): RTCPeerConnection => {
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
for (const track of localStreamRef.current?.getTracks() ?? []) {
|
||||
pc.addTrack(track, localStreamRef.current!);
|
||||
}
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
socket.emit("call:signal", {
|
||||
to: peerId,
|
||||
signal: { candidate: e.candidate.toJSON() },
|
||||
});
|
||||
}
|
||||
};
|
||||
pc.ontrack = (e) => {
|
||||
upsertPeer({ socketId: peerId, userName, stream: e.streams[0] ?? null });
|
||||
};
|
||||
pcs.set(peerId, pc);
|
||||
upsertPeer({ socketId: peerId, userName });
|
||||
return pc;
|
||||
};
|
||||
|
||||
const onPeerJoined = (peer: CallPeer) => {
|
||||
// The newcomer offers to us; just record their name for now.
|
||||
upsertPeer({ socketId: peer.socketId, userName: peer.userName });
|
||||
};
|
||||
|
||||
const onSignal = async ({ from, signal }: { from: string; signal: Signal }) => {
|
||||
let pc = pcs.get(from);
|
||||
try {
|
||||
if (signal.sdp) {
|
||||
if (!pc) pc = createPc(from, "");
|
||||
await pc.setRemoteDescription(signal.sdp);
|
||||
if (signal.sdp.type === "offer") {
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
socket.emit("call:signal", { to: from, signal: { sdp: answer } });
|
||||
}
|
||||
} else if (signal.candidate && pc) {
|
||||
await pc.addIceCandidate(signal.candidate).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* a failed negotiation drops just this peer link */
|
||||
}
|
||||
};
|
||||
|
||||
const onPeerLeft = ({ socketId }: { socketId: string }) => removePeer(socketId);
|
||||
|
||||
const start = async () => {
|
||||
setJoinState("joining");
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
});
|
||||
if (cancelled) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
return;
|
||||
}
|
||||
localStreamRef.current = stream;
|
||||
cameraTrackRef.current = stream.getVideoTracks()[0] ?? null;
|
||||
setLocalStream(stream);
|
||||
|
||||
socket.on("call:peer-joined", onPeerJoined);
|
||||
socket.on("call:signal", onSignal);
|
||||
socket.on("call:peer-left", onPeerLeft);
|
||||
|
||||
socket.emit(
|
||||
"call:join",
|
||||
roomId,
|
||||
(res: { ok: boolean; reason?: string; peers?: CallPeer[] }) => {
|
||||
if (cancelled) return;
|
||||
if (!res?.ok) {
|
||||
setJoinState(res?.reason === "full" ? "full" : "error");
|
||||
return;
|
||||
}
|
||||
setJoinState("joined");
|
||||
// We're the newcomer: initiate an offer to each existing peer.
|
||||
for (const peer of res.peers ?? []) {
|
||||
const pc = createPc(peer.socketId, peer.userName);
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
|
||||
.then((offer) =>
|
||||
socket.emit("call:signal", {
|
||||
to: peer.socketId,
|
||||
signal: { sdp: offer },
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setJoinState("error");
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit("call:leave", roomId);
|
||||
socket.off("call:peer-joined", onPeerJoined);
|
||||
socket.off("call:signal", onSignal);
|
||||
socket.off("call:peer-left", onPeerLeft);
|
||||
for (const pc of pcs.values()) pc.close();
|
||||
pcs.clear();
|
||||
for (const t of localStreamRef.current?.getTracks() ?? []) t.stop();
|
||||
for (const t of screenStreamRef.current?.getTracks() ?? []) t.stop();
|
||||
localStreamRef.current = null;
|
||||
screenStreamRef.current = null;
|
||||
setPeers([]);
|
||||
setLocalStream(null);
|
||||
setJoinState("idle");
|
||||
};
|
||||
}, [roomId, upsertPeer, removePeer]);
|
||||
|
||||
const toggleMic = useCallback(() => {
|
||||
const track = localStreamRef.current?.getAudioTracks()[0];
|
||||
if (!track) return;
|
||||
track.enabled = !track.enabled;
|
||||
setMicOn(track.enabled);
|
||||
}, []);
|
||||
|
||||
const toggleCam = useCallback(() => {
|
||||
const track = localStreamRef.current?.getVideoTracks()[0];
|
||||
if (!track) return;
|
||||
track.enabled = !track.enabled;
|
||||
setCamOn(track.enabled);
|
||||
}, []);
|
||||
|
||||
// Swap the outgoing video track on every peer connection between camera and
|
||||
// screen share via sender.replaceTrack (no renegotiation needed).
|
||||
const replaceVideoTrack = useCallback((track: MediaStreamTrack) => {
|
||||
for (const pc of pcsRef.current.values()) {
|
||||
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||
void sender?.replaceTrack(track);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleScreen = useCallback(async () => {
|
||||
if (screenOn) {
|
||||
const cam = cameraTrackRef.current;
|
||||
if (cam) replaceVideoTrack(cam);
|
||||
for (const t of screenStreamRef.current?.getTracks() ?? []) t.stop();
|
||||
screenStreamRef.current = null;
|
||||
setScreenOn(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
const screenTrack = display.getVideoTracks()[0];
|
||||
if (!screenTrack) return;
|
||||
screenStreamRef.current = display;
|
||||
replaceVideoTrack(screenTrack);
|
||||
setScreenOn(true);
|
||||
// Revert to camera when the user stops sharing from the browser UI.
|
||||
screenTrack.onended = () => {
|
||||
const cam = cameraTrackRef.current;
|
||||
if (cam) replaceVideoTrack(cam);
|
||||
screenStreamRef.current = null;
|
||||
setScreenOn(false);
|
||||
};
|
||||
} catch {
|
||||
/* user dismissed the picker */
|
||||
}
|
||||
}, [screenOn, replaceVideoTrack]);
|
||||
|
||||
return {
|
||||
localStream,
|
||||
peers,
|
||||
joinState,
|
||||
micOn,
|
||||
camOn,
|
||||
screenOn,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
toggleScreen,
|
||||
maxPeers: MAX_CALL_PEERS,
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import {
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Mail,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
SendHorizonal,
|
||||
ShieldAlert,
|
||||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
@@ -91,7 +95,32 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||
// shared-appointment card. Alignment (left/right) comes from the parent column.
|
||||
function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [apptOpen, setApptOpen] = useState(false);
|
||||
if (att.kind === "passwordReset") {
|
||||
return (
|
||||
<button
|
||||
className="max-w-[75%] rounded-2xl border border-warning/40 bg-warning/5 p-3 text-left text-sm transition-colors hover:bg-warning/10"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/settings?tab=careTeam&member=${encodeURIComponent(att.userId)}`,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-warning text-xs">
|
||||
<KeyRound className="size-3.5" />
|
||||
{t("messages.system.label")}
|
||||
</div>
|
||||
<p className="mt-1 font-medium text-foreground">
|
||||
{t("messages.system.passwordResetTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("messages.system.passwordResetBody", { name: att.userName })}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (att.kind === "file") {
|
||||
return (
|
||||
<button
|
||||
@@ -145,6 +174,13 @@ export function MessagesView() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Deep link from a notification: /messages?conversation=<id>.
|
||||
const searchParams = useSearchParams();
|
||||
const deepLinkConversation = searchParams.get("conversation");
|
||||
const openedDeepLink = useRef<string | null>(null);
|
||||
|
||||
// "Today" / "Yesterday" / "Jun 9, 2026" for the thread's day separators.
|
||||
const formatDay = (iso: string): string => {
|
||||
const date = new Date(iso);
|
||||
@@ -281,6 +317,17 @@ export function MessagesView() {
|
||||
);
|
||||
};
|
||||
|
||||
// Once the inbox has loaded, auto-open a conversation deep-linked from a
|
||||
// notification. Guarded so it only fires once per target id.
|
||||
useEffect(() => {
|
||||
if (!deepLinkConversation || conversations.length === 0) return;
|
||||
if (openedDeepLink.current === deepLinkConversation) return;
|
||||
openedDeepLink.current = deepLinkConversation;
|
||||
open(deepLinkConversation);
|
||||
// `open` is stable enough for this one-shot; deps intentionally minimal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deepLinkConversation, conversations]);
|
||||
|
||||
const send = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
@@ -438,18 +485,52 @@ export function MessagesView() {
|
||||
) : (
|
||||
visible.map((c) => {
|
||||
const last = c.lastMessage;
|
||||
const otherId = c.isGroup
|
||||
? ""
|
||||
: (c.participants.find((p) => p.id !== myId)?.id ?? "");
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
|
||||
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
|
||||
selected?.id === c.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={c.id}
|
||||
onClick={() => open(c.id)}
|
||||
type="button"
|
||||
>
|
||||
{c.isSystem ? (
|
||||
<span className="size-9 shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
aria-label={t("messages.startCall", { name: c.name })}
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
otherId
|
||||
? `/messages/meetings?with=${encodeURIComponent(otherId)}`
|
||||
: "/messages/meetings",
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Video className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
|
||||
onClick={() => open(c.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarFallback>{initials(c.name)}</AvatarFallback>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
c.isSystem && "bg-warning/15 text-warning",
|
||||
)}
|
||||
>
|
||||
{c.isSystem ? (
|
||||
<ShieldAlert className="size-4" />
|
||||
) : (
|
||||
initials(c.name)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
@@ -463,6 +544,11 @@ export function MessagesView() {
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
{c.isSystem && (
|
||||
<span className="shrink-0 rounded-full bg-warning/15 px-1.5 py-0.5 font-medium text-[10px] text-warning uppercase tracking-wide">
|
||||
{t("messages.system.tag")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
@@ -493,7 +579,8 @@ export function MessagesView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -506,18 +593,30 @@ export function MessagesView() {
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
|
||||
<Avatar className="size-9">
|
||||
<AvatarFallback>{initials(selected.name)}</AvatarFallback>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
selected.isSystem && "bg-warning/15 text-warning",
|
||||
)}
|
||||
>
|
||||
{selected.isSystem ? (
|
||||
<ShieldAlert className="size-4" />
|
||||
) : (
|
||||
initials(selected.name)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selected.isGroup
|
||||
? t("messages.peopleCount", {
|
||||
count: selected.participants.length,
|
||||
})
|
||||
: t("messages.directMessage")}
|
||||
{selected.isSystem
|
||||
? t("messages.system.label")
|
||||
: selected.isGroup
|
||||
? t("messages.peopleCount", {
|
||||
count: selected.participants.length,
|
||||
})
|
||||
: t("messages.directMessage")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,12 +707,18 @@ export function MessagesView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.isSystem ? (
|
||||
<div className="flex items-center justify-center gap-2 rounded-3xl border border-input bg-muted/40 px-4 py-3 text-center text-muted-foreground text-sm">
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
{t("messages.system.readOnly")}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
className="flex flex-col rounded-3xl border border-input bg-background shadow-sm transition-colors focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/20"
|
||||
onSubmit={send}
|
||||
>
|
||||
{pending.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-3">
|
||||
{pending.map((att, i) => (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||
@@ -627,7 +732,9 @@ export function MessagesView() {
|
||||
<span className="max-w-40 truncate">
|
||||
{att.kind === "file"
|
||||
? att.fileName
|
||||
: att.appointment.name}
|
||||
: att.kind === "appointment"
|
||||
? att.appointment.name
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t("messages.attach.remove")}
|
||||
@@ -641,52 +748,64 @@ export function MessagesView() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The attach control is a native <label> wrapping the file
|
||||
input, so the browser opens the picker on a trusted click.
|
||||
A programmatic `inputRef.click()` (the old menu item) gets
|
||||
dropped by user-activation gating once the menu closes —
|
||||
which is why attaching silently failed and no chip appeared. */}
|
||||
<label
|
||||
aria-label={t("messages.attach.file")}
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
uploading && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
aria-label={t("messages.attach.appointment")}
|
||||
disabled={uploading}
|
||||
onClick={openApptPicker}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CalendarClock className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
<textarea
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="field-sizing-content block max-h-40 min-h-11 w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter inserts a newline.
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
value={draft}
|
||||
/>
|
||||
}}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
}
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The attach control is a native <label> wrapping the file
|
||||
input, so the browser opens the picker on a trusted click.
|
||||
A programmatic `inputRef.click()` (the old menu item) gets
|
||||
dropped by user-activation gating once the menu closes —
|
||||
which is why attaching silently failed and no chip appeared. */}
|
||||
<label
|
||||
aria-label={t("messages.attach.file")}
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
uploading && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
aria-label={t("messages.attach.appointment")}
|
||||
className="rounded-full"
|
||||
disabled={uploading}
|
||||
onClick={openApptPicker}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CalendarClock className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
className="size-9 shrink-0 rounded-full"
|
||||
disabled={!draft.trim() && pending.length === 0}
|
||||
size="icon"
|
||||
type="submit"
|
||||
@@ -695,6 +814,7 @@ export function MessagesView() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import QRCodeSvg from "react-qr-code";
|
||||
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import {
|
||||
commitWalletShare,
|
||||
type Patient,
|
||||
pollWalletShare,
|
||||
requestWalletPairing,
|
||||
requestWalletShare,
|
||||
type WalletShareRequest,
|
||||
} from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Phase =
|
||||
| "form"
|
||||
| "requesting"
|
||||
| "waiting"
|
||||
| "approved"
|
||||
| "denied"
|
||||
| "expired"
|
||||
| "error";
|
||||
|
||||
type Mode = "number" | "qr";
|
||||
|
||||
const DURATIONS = [
|
||||
{ hours: 1, key: "hours", count: 1 },
|
||||
{ hours: 24, key: "days", count: 1 },
|
||||
{ hours: 168, key: "days", count: 7 },
|
||||
] as const;
|
||||
|
||||
const POLL_INTERVAL = 2500;
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000;
|
||||
|
||||
export function ImportFromWalletDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported?: (fileNumber: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<Mode>("number");
|
||||
const [walletNumber, setWalletNumber] = useState("");
|
||||
const [temporary, setTemporary] = useState(false);
|
||||
const [durationHours, setDurationHours] = useState<number>(24);
|
||||
const [phase, setPhase] = useState<Phase>("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [request, setRequest] = useState<WalletShareRequest | null>(null);
|
||||
const [pairUri, setPairUri] = useState<string | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer.current) {
|
||||
clearInterval(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset everything whenever the dialog is (re)opened.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode("number");
|
||||
setWalletNumber("");
|
||||
setTemporary(false);
|
||||
setDurationHours(24);
|
||||
setPhase("form");
|
||||
setError(null);
|
||||
setRequest(null);
|
||||
setPairUri(null);
|
||||
setReviewOpen(false);
|
||||
}
|
||||
return stopPolling;
|
||||
}, [open]);
|
||||
|
||||
// Poll the request until the patient approves/denies on their device.
|
||||
useEffect(() => {
|
||||
if (phase !== "waiting" || !request) return;
|
||||
const startedAt = Date.now();
|
||||
pollTimer.current = setInterval(async () => {
|
||||
try {
|
||||
const next = await pollWalletShare(request.id);
|
||||
if (next.status === "approved") {
|
||||
stopPolling();
|
||||
setRequest(next);
|
||||
setPhase("approved");
|
||||
} else if (next.status === "denied") {
|
||||
stopPolling();
|
||||
setPhase("denied");
|
||||
} else if (
|
||||
next.status === "expired" ||
|
||||
Date.now() - startedAt > POLL_TIMEOUT
|
||||
) {
|
||||
stopPolling();
|
||||
setPhase("expired");
|
||||
}
|
||||
} catch {
|
||||
/* transient — keep polling until timeout */
|
||||
if (Date.now() - startedAt > POLL_TIMEOUT) {
|
||||
stopPolling();
|
||||
setPhase("expired");
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
return stopPolling;
|
||||
}, [phase, request]);
|
||||
|
||||
const sendRequest = async () => {
|
||||
setPhase("requesting");
|
||||
setError(null);
|
||||
try {
|
||||
const req = await requestWalletShare({
|
||||
walletNumber: walletNumber.trim(),
|
||||
mode: temporary ? "temporary" : "permanent",
|
||||
durationHours: temporary ? durationHours : undefined,
|
||||
});
|
||||
setRequest(req);
|
||||
setPhase("waiting");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setError(t("patients.importApp.invalidWallet"));
|
||||
} else {
|
||||
setError(t("patients.importApp.error"));
|
||||
}
|
||||
setPhase("error");
|
||||
}
|
||||
};
|
||||
|
||||
// QR flow: create a pairing request (no wallet number) and build the
|
||||
// `temetro-pair:` URI the app scans (this clinic's relay URL + request + key).
|
||||
const startPairing = async () => {
|
||||
setPhase("requesting");
|
||||
setError(null);
|
||||
try {
|
||||
const pairing = await requestWalletPairing({
|
||||
mode: temporary ? "temporary" : "permanent",
|
||||
durationHours: temporary ? durationHours : undefined,
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
// Use the server-resolved, phone-reachable relay URL — NOT API_BASE_URL,
|
||||
// which is often http://localhost:4000 (the phone itself) and never
|
||||
// connects from a real device.
|
||||
relay: pairing.relayUrl,
|
||||
rid: pairing.id,
|
||||
epk: pairing.ephemeralPubKey,
|
||||
mode: pairing.shareMode,
|
||||
});
|
||||
if (temporary) params.set("dur", String(durationHours));
|
||||
setPairUri(`temetro-pair:?${params.toString()}`);
|
||||
setRequest(pairing);
|
||||
setPhase("waiting");
|
||||
} catch {
|
||||
setError(t("patients.importApp.error"));
|
||||
setPhase("error");
|
||||
}
|
||||
};
|
||||
|
||||
const commitDraft = async (record: Patient) => {
|
||||
if (!request) return;
|
||||
try {
|
||||
const saved = await commitWalletShare(request.id, record);
|
||||
setReviewOpen(false);
|
||||
onOpenChange(false);
|
||||
onImported?.(saved.fileNumber);
|
||||
notify.success(
|
||||
t("patients.importApp.savedTitle"),
|
||||
t("patients.importApp.savedBody", { name: saved.name }),
|
||||
);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
t("patients.importApp.errorTitle"),
|
||||
err instanceof Error ? err.message : t("patients.importApp.error"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const durationLabel = (d: (typeof DURATIONS)[number]) =>
|
||||
t(`patients.importApp.${d.key}`, { count: d.count });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importApp.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("patients.importApp.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
{phase === "waiting" && mode === "qr" && pairUri ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<div className="rounded-2xl bg-white p-4">
|
||||
<QRCodeSvg value={pairUri} size={196} />
|
||||
</div>
|
||||
<p className="font-medium text-sm">
|
||||
{t("patients.importApp.qrCaption")}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t("patients.importApp.waitingTitle")}
|
||||
</p>
|
||||
</div>
|
||||
) : phase === "waiting" ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
<p className="font-medium text-sm">
|
||||
{t("patients.importApp.waitingTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("patients.importApp.waitingBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : phase === "approved" ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Check className="size-8 text-emerald-500" />
|
||||
<p className="font-medium text-sm">
|
||||
{t("patients.importApp.approvedTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("patients.importApp.approvedBody")}
|
||||
</p>
|
||||
<Button onClick={() => setReviewOpen(true)} type="button">
|
||||
{t("patients.importApp.review")}
|
||||
</Button>
|
||||
</div>
|
||||
) : phase === "denied" || phase === "expired" ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<X className="size-8 text-muted-foreground" />
|
||||
<p className="font-medium text-sm">
|
||||
{t(`patients.importApp.${phase}Title`)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`patients.importApp.${phase}Body`)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 rounded-2xl bg-muted/50 p-1">
|
||||
<Button
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={() => setMode("number")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={mode === "number" ? "default" : "ghost"}
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importApp.tabNumber")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={() => setMode("qr")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={mode === "qr" ? "default" : "ghost"}
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
{t("patients.importApp.tabQr")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mode === "number" ? (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("patients.importApp.walletLabel")}
|
||||
</span>
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={phase === "requesting"}
|
||||
onChange={(e) => setWalletNumber(e.target.value)}
|
||||
placeholder={t("patients.importApp.walletPlaceholder")}
|
||||
value={walletNumber}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("patients.importApp.qrHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4 rounded-2xl border border-border bg-card/30 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">
|
||||
{t("patients.importApp.tempLabel")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("patients.importApp.tempHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={temporary}
|
||||
disabled={phase === "requesting"}
|
||||
onCheckedChange={(v) => setTemporary(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{temporary ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("patients.importApp.durationLabel")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{DURATIONS.map((d) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"flex-1 rounded-2xl",
|
||||
durationHours !== d.hours &&
|
||||
"bg-transparent text-foreground",
|
||||
)}
|
||||
key={d.hours}
|
||||
onClick={() => setDurationHours(d.hours)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={
|
||||
durationHours === d.hours ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
{durationLabel(d)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{phase === "approved" || phase === "denied" || phase === "expired"
|
||||
? t("patients.importApp.close")
|
||||
: t("patients.importApp.cancel")}
|
||||
</DialogClose>
|
||||
{(phase === "form" || phase === "requesting" || phase === "error") &&
|
||||
mode === "number" ? (
|
||||
<Button
|
||||
disabled={!walletNumber.trim() || phase === "requesting"}
|
||||
onClick={sendRequest}
|
||||
type="button"
|
||||
>
|
||||
{phase === "requesting"
|
||||
? t("patients.importApp.requesting")
|
||||
: t("patients.importApp.request")}
|
||||
</Button>
|
||||
) : (phase === "form" || phase === "requesting" || phase === "error") &&
|
||||
mode === "qr" ? (
|
||||
<Button
|
||||
disabled={phase === "requesting"}
|
||||
onClick={startPairing}
|
||||
type="button"
|
||||
>
|
||||
{phase === "requesting"
|
||||
? t("patients.importApp.requesting")
|
||||
: t("patients.importApp.generateQr")}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
{/* Review the shared record in the full patient form (review mode — the
|
||||
form emits the draft, we commit it via the wallet-share endpoint). */}
|
||||
{request?.draft ? (
|
||||
<PatientFormDialog
|
||||
mode="edit"
|
||||
onDraft={commitDraft}
|
||||
onOpenChange={setReviewOpen}
|
||||
open={reviewOpen}
|
||||
patient={request.draft}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/invoices";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
import type { Prescription } from "@/lib/prescriptions";
|
||||
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A record "file" surfaced both in the graph and the sheet's clickable list.
|
||||
@@ -132,6 +133,24 @@ export function PatientDetail({
|
||||
// The record "file" opened in a detail dialog from the records list.
|
||||
const [openFile, setOpenFile] = useState<RecordFile | null>(null);
|
||||
|
||||
// Resolve the responsible clinician's specialty (set by an admin in Care
|
||||
// Team) to show alongside the primary-care provider.
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
useEffect(() => {
|
||||
if (!patient.primaryProviderId) return;
|
||||
let active = true;
|
||||
listProviders()
|
||||
.then((p) => active && setProviders(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [patient.primaryProviderId]);
|
||||
const providerSpecialty = specialtyLabel(
|
||||
t,
|
||||
providers.find((p) => p.userId === patient.primaryProviderId)?.specialty,
|
||||
);
|
||||
|
||||
// The same problems + visits the graph plots, as a clickable list.
|
||||
const files: RecordFile[] = [
|
||||
...patient.problems.map((p, i) => ({
|
||||
@@ -216,7 +235,9 @@ export function PatientDetail({
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<Stat
|
||||
label={t("patientCard.summary.primaryCare")}
|
||||
value={patient.pcp}
|
||||
value={
|
||||
providerSpecialty ? `${patient.pcp} · ${providerSpecialty}` : patient.pcp
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.lastSeen")}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Search, Smartphone } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AiBadge } from "@/components/ai-badge";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet-dialog";
|
||||
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +26,7 @@ export function PatientsView() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
// Bumped on open so the create dialog remounts with a fresh file # / form.
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
|
||||
@@ -68,6 +71,16 @@ export function PatientsView() {
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
// Deep link from a notification: /patients?file=<fileNumber> opens the record.
|
||||
const searchParams = useSearchParams();
|
||||
const deepLinkFile = searchParams.get("file");
|
||||
const openedDeepLink = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!deepLinkFile || openedDeepLink.current === deepLinkFile) return;
|
||||
openedDeepLink.current = deepLinkFile;
|
||||
open(deepLinkFile);
|
||||
}, [deepLinkFile]);
|
||||
|
||||
const refresh = () => {
|
||||
void listPatients()
|
||||
.then(setAllPatients)
|
||||
@@ -99,6 +112,15 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setImportOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => {
|
||||
@@ -177,6 +199,11 @@ export function PatientsView() {
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<AiBadge source={p.source} />
|
||||
{p.shareExpiresAt ? (
|
||||
<Badge variant="outline">
|
||||
{t("patients.tempBadge")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
@@ -214,6 +241,15 @@ export function PatientsView() {
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<ImportFromWalletDialog
|
||||
onImported={(fileNumber) => {
|
||||
refresh();
|
||||
open(fileNumber);
|
||||
}}
|
||||
onOpenChange={setImportOpen}
|
||||
open={importOpen}
|
||||
/>
|
||||
|
||||
<PatientDetailSheet
|
||||
fileNumber={selected}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarCheck,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
bookPortalAppointment,
|
||||
createPortalPatient,
|
||||
getPortalClinic,
|
||||
lookupPortalResults,
|
||||
type PortalBookingResult,
|
||||
type PortalResults,
|
||||
} from "@/lib/portal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "choose" | "book" | "results";
|
||||
|
||||
const todayKey = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5 text-left">
|
||||
<span className="font-medium text-foreground text-sm">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalKiosk({ clinic }: { clinic: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [clinicName, setClinicName] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [step, setStep] = useState<Step>("choose");
|
||||
|
||||
useEffect(() => {
|
||||
getPortalClinic(clinic)
|
||||
.then((c) => setClinicName(c.name))
|
||||
.catch(() => setNotFound(true));
|
||||
}, [clinic]);
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center p-6">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CalendarCheck />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("portal.notFoundTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{t("portal.notFoundBody")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col items-center justify-center gap-8 px-6 py-12">
|
||||
<header className="flex flex-col items-center gap-2 text-center">
|
||||
<span className="font-medium text-muted-foreground text-sm uppercase tracking-wide">
|
||||
{t("portal.kicker")}
|
||||
</span>
|
||||
<h1 className="font-semibold text-3xl tracking-tight sm:text-4xl">
|
||||
{clinicName ?? "…"}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{step === "choose" ? (
|
||||
<ChooseStep onPick={setStep} />
|
||||
) : step === "book" ? (
|
||||
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
) : (
|
||||
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card-style radio: two large, touch-friendly choices.
|
||||
function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cards: { step: Step; icon: React.ReactNode; title: string; desc: string }[] =
|
||||
[
|
||||
{
|
||||
step: "book",
|
||||
icon: <CalendarPlus className="size-7" />,
|
||||
title: t("portal.choose.bookTitle"),
|
||||
desc: t("portal.choose.bookDesc"),
|
||||
},
|
||||
{
|
||||
step: "results",
|
||||
icon: <FlaskConical className="size-7" />,
|
||||
title: t("portal.choose.resultsTitle"),
|
||||
desc: t("portal.choose.resultsDesc"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2">
|
||||
{cards.map((c) => (
|
||||
<button
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
key={c.step}
|
||||
onClick={() => onPick(c.step)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
|
||||
{c.title}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{c.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackButton({ onBack }: { onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 self-start text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("portal.back")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
// "returning" = has a file number; "new" = register first, then book.
|
||||
const [mode, setMode] = useState<"returning" | "new">("returning");
|
||||
const [name, setName] = useState("");
|
||||
const [fileNumber, setFileNumber] = useState("");
|
||||
const [sex, setSex] = useState("M");
|
||||
const [age, setAge] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("09:00");
|
||||
const [type, setType] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<PortalBookingResult | null>(null);
|
||||
// The file number assigned to a freshly-registered patient (shown on success).
|
||||
const [newFile, setNewFile] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// A new patient is registered first to obtain a file number.
|
||||
let file = fileNumber.trim();
|
||||
if (mode === "new") {
|
||||
const created = await createPortalPatient(clinic, {
|
||||
name: name.trim(),
|
||||
sex,
|
||||
age: age ? Number(age) : undefined,
|
||||
});
|
||||
file = created.fileNumber;
|
||||
setNewFile(created.fileNumber);
|
||||
}
|
||||
const result = await bookPortalAppointment(clinic, {
|
||||
name: name.trim(),
|
||||
fileNumber: file,
|
||||
date,
|
||||
time,
|
||||
type: type.trim() || undefined,
|
||||
});
|
||||
setDone(result);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("portal.book.errorGeneric"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("portal.book.successTitle")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t("portal.book.successBody", {
|
||||
date: done.date,
|
||||
time: done.time,
|
||||
})}
|
||||
{newFile
|
||||
? ` ${t("portal.book.newFileNote", { file: newFile })}`
|
||||
: ""}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<Button onClick={onBack} variant="outline">
|
||||
{t("portal.done")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">{t("portal.book.title")}</h2>
|
||||
|
||||
{/* Returning vs new patient. */}
|
||||
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-muted/40 p-1">
|
||||
{(["returning", "new"] as const).map((m) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
mode === m
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
type="button"
|
||||
>
|
||||
{t(`portal.book.mode.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Field label={t("portal.field.name")}>
|
||||
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
||||
</Field>
|
||||
|
||||
{mode === "returning" ? (
|
||||
<Field label={t("portal.field.fileNumber")}>
|
||||
<Input
|
||||
onChange={(e) => setFileNumber(e.target.value)}
|
||||
required
|
||||
value={fileNumber}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("portal.field.sex")}>
|
||||
<select
|
||||
className="h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
onChange={(e) => setSex(e.target.value)}
|
||||
value={sex}
|
||||
>
|
||||
<option value="M">{t("portal.field.sexMale")}</option>
|
||||
<option value="F">{t("portal.field.sexFemale")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("portal.field.age")}>
|
||||
<Input
|
||||
max={150}
|
||||
min={0}
|
||||
onChange={(e) => setAge(e.target.value)}
|
||||
type="number"
|
||||
value={age}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("portal.field.date")}>
|
||||
<Input
|
||||
min={todayKey()}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
type="date"
|
||||
value={date}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.field.time")}>
|
||||
<Input
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
required
|
||||
type="time"
|
||||
value={time}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("portal.field.reason")}>
|
||||
<Input
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder={t("portal.field.reasonPlaceholder")}
|
||||
value={type}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
<Button disabled={busy} size="lg" type="submit">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{t("portal.book.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [fileNumber, setFileNumber] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<PortalResults | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(
|
||||
await lookupPortalResults(clinic, fileNumber.trim(), name.trim()),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("portal.results.errorGeneric"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (data) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">
|
||||
{t("portal.results.greeting", { name: data.name })}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-2xl border bg-card/40 p-4">
|
||||
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("portal.results.upcoming")}
|
||||
</p>
|
||||
{data.upcoming.length === 0 ? (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("portal.results.noUpcoming")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{data.upcoming.map((a) => (
|
||||
<li
|
||||
className="flex items-center justify-between gap-3 rounded-xl border bg-card px-3 py-2"
|
||||
key={`${a.date}-${a.time}`}
|
||||
>
|
||||
<span className="font-medium text-foreground text-sm tabular-nums">
|
||||
{a.date} · {a.time}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-sm">
|
||||
{a.type} · {a.provider}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card/40 p-4">
|
||||
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("portal.results.resultsLabel")}
|
||||
</p>
|
||||
<p className="mt-2 text-foreground text-sm">
|
||||
{data.hasResults
|
||||
? t("portal.results.ready", { count: data.resultCount })
|
||||
: t("portal.results.none")}
|
||||
</p>
|
||||
{data.hasResults ? (
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{t("portal.results.askStaff")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setData(null)} variant="outline">
|
||||
{t("portal.results.lookupAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">{t("portal.results.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("portal.results.subtitle")}</p>
|
||||
<Field label={t("portal.field.name")}>
|
||||
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
||||
</Field>
|
||||
<Field label={t("portal.field.fileNumber")}>
|
||||
<Input
|
||||
onChange={(e) => setFileNumber(e.target.value)}
|
||||
required
|
||||
value={fileNumber}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
<Button disabled={busy} size="lg" type="submit">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{t("portal.results.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
@@ -34,8 +35,17 @@ import {
|
||||
import { ROLE_LABELS } from "@/lib/access";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles";
|
||||
import {
|
||||
SPECIALTIES,
|
||||
setStaffPassword,
|
||||
specialtyLabel,
|
||||
updateStaffSpecialty,
|
||||
} from "@/lib/staff";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// Roles that can carry a clinical specialty (i.e. treat patients).
|
||||
const PROVIDER_ROLES = new Set(["owner", "admin", "doctor", "member"]);
|
||||
|
||||
// Icon shown next to each permission resource row.
|
||||
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
patient: <Users className="size-4" />,
|
||||
@@ -53,6 +63,7 @@ export type StaffMember = {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
username: string | null;
|
||||
specialty: string | null;
|
||||
};
|
||||
|
||||
function roleLabel(role?: string | null): string {
|
||||
@@ -98,10 +109,18 @@ export function EmployeeDetailDialog({
|
||||
const { t } = useTranslation();
|
||||
const [role, setRole] = useState<string>(member?.role ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [specialty, setSpecialty] = useState<string>(member?.specialty ?? "");
|
||||
const [savingSpecialty, setSavingSpecialty] = useState(false);
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRole(member?.role ?? "");
|
||||
}, [member?.id, member?.role]);
|
||||
setSpecialty(member?.specialty ?? "");
|
||||
setNewPw("");
|
||||
setConfirmPw("");
|
||||
}, [member?.id, member?.role, member?.specialty]);
|
||||
|
||||
const summary = rolePermissionSummary(member?.role);
|
||||
const secondary = member?.username ? `@${member.username}` : member?.email;
|
||||
@@ -137,6 +156,76 @@ export function EmployeeDetailDialog({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const saveSpecialty = async () => {
|
||||
if (!member || savingSpecialty) return;
|
||||
const next = specialty || null;
|
||||
if (next === (member.specialty ?? null)) return;
|
||||
setSavingSpecialty(true);
|
||||
try {
|
||||
await updateStaffSpecialty(member.userId, next);
|
||||
notify.success(
|
||||
t("settings.careTeam.employee.specialtyUpdatedTitle"),
|
||||
t("settings.careTeam.employee.specialtyUpdatedBody", {
|
||||
name: member.name ?? member.email ?? "",
|
||||
}),
|
||||
);
|
||||
onChanged();
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.careTeam.employee.specialtyFailedTitle"),
|
||||
t("settings.careTeam.employee.specialtyFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSavingSpecialty(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!member || savingPw) return;
|
||||
if (newPw.length < 12) {
|
||||
notify.error(
|
||||
t("settings.careTeam.employee.pwTooShortTitle"),
|
||||
t("settings.careTeam.employee.pwTooShortBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirmPw) {
|
||||
notify.error(
|
||||
t("settings.careTeam.employee.pwMismatchTitle"),
|
||||
t("settings.careTeam.employee.pwMismatchBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSavingPw(true);
|
||||
try {
|
||||
await setStaffPassword(member.userId, newPw);
|
||||
notify.success(
|
||||
t("settings.careTeam.employee.pwUpdatedTitle"),
|
||||
t("settings.careTeam.employee.pwUpdatedBody", {
|
||||
name: member.name ?? member.email ?? "",
|
||||
}),
|
||||
);
|
||||
setNewPw("");
|
||||
setConfirmPw("");
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.careTeam.employee.pwFailedTitle"),
|
||||
t("settings.careTeam.employee.pwFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showSpecialty = PROVIDER_ROLES.has(member?.role ?? "");
|
||||
const specialtyOptions = [
|
||||
{ value: "", label: t("settings.careTeam.employee.noSpecialty") },
|
||||
...SPECIALTIES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`settings.careTeam.specialties.${s}`),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
@@ -163,6 +252,11 @@ export function EmployeeDetailDialog({
|
||||
{secondary}
|
||||
</p>
|
||||
)}
|
||||
{showSpecialty && specialtyLabel(t, member?.specialty) && (
|
||||
<p className="truncate text-primary text-xs">
|
||||
{specialtyLabel(t, member?.specialty)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{roleLabel(member?.role)}
|
||||
@@ -246,6 +340,83 @@ export function EmployeeDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && showSpecialty && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("settings.careTeam.employee.specialty")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
items={specialtyOptions}
|
||||
onValueChange={(value) => setSpecialty(value as string)}
|
||||
value={specialty}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings.careTeam.employee.specialty")}
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{specialtyOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={
|
||||
savingSpecialty ||
|
||||
(specialty || null) === (member?.specialty ?? null)
|
||||
}
|
||||
onClick={saveSpecialty}
|
||||
type="button"
|
||||
>
|
||||
{savingSpecialty
|
||||
? t("settings.careTeam.employee.saving")
|
||||
: t("settings.careTeam.employee.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("settings.careTeam.employee.resetPassword")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.careTeam.employee.resetPasswordHint")}
|
||||
</p>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
placeholder={t("settings.careTeam.employee.newPassword")}
|
||||
type="password"
|
||||
value={newPw}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
placeholder={t("settings.careTeam.employee.confirmPassword")}
|
||||
type="password"
|
||||
value={confirmPw}
|
||||
/>
|
||||
<Button
|
||||
disabled={savingPw || !newPw || !confirmPw}
|
||||
onClick={resetPassword}
|
||||
type="button"
|
||||
>
|
||||
{savingPw
|
||||
? t("settings.careTeam.employee.saving")
|
||||
: t("settings.careTeam.employee.setPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,9 +12,77 @@ import {
|
||||
SettingsSection,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
getSigningKey,
|
||||
listSignedRecords,
|
||||
rotateSigningKey,
|
||||
type SharedRecord,
|
||||
type SigningKey,
|
||||
} from "@/lib/signing";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function SigningPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [key, setKey] = useState<SigningKey | null>(null);
|
||||
const [records, setRecords] = useState<SharedRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
|
||||
.then(([k, r]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(t("settings.signing.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const rotate = async () => {
|
||||
setRotating(true);
|
||||
try {
|
||||
const next = await rotateSigningKey();
|
||||
setKey(next);
|
||||
notify.success(
|
||||
t("settings.signing.rotateSuccessTitle"),
|
||||
t("settings.signing.rotateSuccessBody"),
|
||||
);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.signing.rotateErrorTitle"),
|
||||
t("settings.signing.rotateError"),
|
||||
);
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recordStatusLabel = (status: SharedRecord["status"]): string =>
|
||||
t(
|
||||
`settings.signing.records.status${
|
||||
status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -29,15 +98,35 @@ export function SigningPanel() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.signing.keyDescription")}
|
||||
</p>
|
||||
<Button className={cn("rounded-lg", whiteButton)}>
|
||||
{t("settings.signing.rotateKey")}
|
||||
<Button
|
||||
className={cn("rounded-lg", whiteButton)}
|
||||
disabled={rotating || loading}
|
||||
onClick={rotate}
|
||||
type="button"
|
||||
>
|
||||
{rotating
|
||||
? t("settings.signing.rotating")
|
||||
: t("settings.signing.rotateKey")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.signing.createdAt")}
|
||||
{key
|
||||
? t("settings.signing.createdAtLabel", {
|
||||
date: formatDate(key.createdAt),
|
||||
})
|
||||
: loading
|
||||
? t("settings.signing.loading")
|
||||
: error ?? ""}
|
||||
</p>
|
||||
{key?.rotatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.signing.rotatedAtLabel", {
|
||||
date: formatDate(key.rotatedAt),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -49,7 +138,7 @@ export function SigningPanel() {
|
||||
<CopyField
|
||||
description={t("settings.signing.fingerprintDescription")}
|
||||
label={t("settings.signing.fingerprintLabel")}
|
||||
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
|
||||
value={key?.fingerprint ?? "—"}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
@@ -98,11 +187,41 @@ export function SigningPanel() {
|
||||
description={t("settings.signing.signedRecordsDescription")}
|
||||
title={t("settings.signing.signedRecordsTitle")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.signing.noPending")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
{records.length === 0 ? (
|
||||
<SettingsCard className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.signing.noPending")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard className="divide-y divide-border">
|
||||
{records.map((record) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-4 py-3.5"
|
||||
key={record.id}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate font-mono text-sm">
|
||||
{record.walletNumber}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{record.shareMode === "temporary"
|
||||
? t("settings.signing.records.temporary")
|
||||
: t("settings.signing.records.permanent")}
|
||||
{record.shareExpiresAt
|
||||
? ` · ${t("settings.signing.records.expires", {
|
||||
date: formatDate(record.shareExpiresAt),
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{recordStatusLabel(record.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user