Compare commits

...

99 Commits

Author SHA1 Message Date
Khalid Abdi 62a5f39683 chore: release v0.14.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:22 +03:00
Khalid Abdi 6127a0ac42 frontend: build with webpack so Docker images build on arm64
Next 16's default Turbopack build has no native bindings for linux/arm64 in
the Alpine image, so `next build` failed in Docker ("Turbopack is not
supported on this platform"). Switch the production build to webpack, which
Next recommends for this case and which builds on every arch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:02 +03:00
Khalid Abdi 352ca65838 chore: release v0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:31:54 +03:00
Khalid Abdi 44d653ffbd frontend: compose settings frames from CardFrame primitives
SettingsFrame used a hand-rolled <div className="p-5"> body and
SettingsCard was a plain div (no data-slot=card), so the frame's card
styling never applied.

- Add CardFramePanel (data-slot="card-frame-panel") as the padded frame
  body, mirroring the other CardFrame* subcomponents.
- SettingsFrame now composes CardFrameHeader + CardFramePanel (same p-5
  padding as before) instead of a raw div.
- SettingsCard renders a real COSS Card (data-slot=card). Since Card is
  flex flex-col, the horizontal-row call sites (ToggleRow, billing and
  preferences rows) now pass flex-row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:19 +03:00
Khalid Abdi 76da310766 frontend: fix wallet push silently skipping in form dialogs
The "send to wallet" step in the appointment/invoice/prescription/
patient-edit/scribe dialogs was gated on sync.linked, which resolves
asynchronously via getWalletLink in an effect. A fast save (or a
transient failure that collapsed it to false) left a wallet-linked
patient looking unlinked, so the dialog took the else branch and pushed
nothing.

- Add ensureLinked() to useWalletSync: awaits getWalletLink at submit and
  returns the resolved status. Each of the 5 dialogs now awaits it before
  deciding whether to show the wallet step.
- Harden push() to drop empty/whitespace changes (the backend 400s on an
  empty change set) and add a translated summaryFallback so the step
  never sends an empty summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:04 +03:00
Khalid Abdi c326e9f794 chore: release v0.14.0
Bump root/backend/frontend to 0.14.0, move Unreleased notes under a dated
0.14.0 heading, and fix the stale README changelog badge (0.2.1 → 0.14.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:48 +03:00
Khalid Abdi b299501ab2 frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push
- patients: move status filter to its own "Filter" row above the table
- patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu
- settings: redesign sections with the COSS CardFrame surface (SettingsFrame)
- AI: add Automatic (auto-pick provider) and Off modes; default to Automatic
  so a fresh install shows the setup banner until a provider is configured
- AI: fix the setup banner never showing (defaulted Ollama URL counted as
  configured); add an "AI off" notice; add a fallback render for unknown chat
  data parts so cards never silently vanish
- backend: include patient attachment metadata in the wallet record-update
  bundle so pushed documents reach the wallet
- i18n: add mode/off/card keys across all five locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:36 +03:00
Khalid Abdi 17209a2cf1 chore: release v0.13.1
Bump version to 0.13.1 across root/backend/frontend and add the 0.13.1
CHANGELOG entry (COSS checkbox/table on the web, wallet-app home sheets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:16:07 +03:00
Khalid Abdi bd8fdfadda frontend: COSS checkbox/table + fewer toolbar/sheet buttons
- Invoice dialog: the Back-date / Due-date toggles now use the COSS Checkbox
  instead of raw, misshapen native checkboxes.
- Patients page: rebuilt the list as a COSS Table in a CardFrame, and moved
  "Import from a patient app" into a ⋯ overflow menu so the toolbar keeps one
  primary "Add patient" CTA.
- Patient detail sheet: collapsed the five header buttons + delete into a
  primary Edit action plus a ⋯ More menu (Download summary, Record visit,
  Transfer, Push to wallet, and a destructive Delete).
- Added the COSS table + checkbox primitives; new i18n keys across all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:11:06 +03:00
Khalid Abdi 7846dacd42 chore: release v0.13.0
Bump version to 0.13.0 across root/backend/frontend, add the 0.13.0 CHANGELOG
entry, and refresh stale CLAUDE.md docs (the AI chat is real and @ai-sdk/react
is installed; the signing/approval flow is built).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:17:55 +03:00
Khalid Abdi 2c239fbd27 frontend: UI fixes + settings/patients features
- Record history: replace fragile COSS Timeline separator math with a
  continuous-rail list so every audited change renders in full (RTL-safe).
- Prescriptions: new reusable COSS DatePicker (Popover + Calendar) replaces
  raw <input type="date"> for Start/End date.
- Update banner: rebuilt with COSS Alert variant="warning"; pinned physical
  bottom-right so it stays bottom-right under Arabic RTL.
- AI setup notice: thin attached warning banner with clearer copy; now shown
  above the input in active chats too, not just the empty state.
- Settings profile: wire the previously dead Specialty (Select) and
  Professional links (editable rows) into the persisted preferences.
- Patients: add a status filter and broaden search to conditions/allergies.
- Sidebar user menu: quick language switch submenu (RTL already wired).
- ai-elements: fix a subset of Base UI type drift (22 -> 12; remainder is
  cmdk->Autocomplete migration drift kept behind ignoreBuildErrors).
- i18n: new keys added across all five locales (en/de/fr/ar/so).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:06:38 +03:00
Khalid Abdi bffed5525d chore: release v0.12.1
Bump root/backend/frontend to 0.12.1 and move the CHANGELOG notes under a dated
0.12.1 heading (centered wallet-sync stepper, timeline record history, and the
landing-page globe deferral). Adds the radix-ui dependency pulled in by the
Origin UI stepper/timeline primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:51:04 +03:00
Khalid Abdi 591b2f9170 frontend: center wallet-sync stepper and record-history timeline
Replace the bespoke left-aligned DialogStepper with the Origin UI Stepper
primitive (components/ui/stepper.tsx) so the numbered indicators sit centered
inline with their labels. Rebuild the patient sheet's Record History section as
a vertical Timeline (components/ui/timeline.tsx) showing the actor name, an
entity-type icon, the action, and the date, replacing the flat avatar list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:50:54 +03:00
Khalid Abdi b557139752 chore: release v0.12.0
Bump to 0.12.0 and record the changelog: in-dialog send-to-wallet stepper,
past-date-blocking appointment/invoice pickers, wallet-number-only portal link,
and removal of the stale Settings Features section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:23:52 +03:00
Khalid Abdi 2d7df1bec0 backend: portal wallet link identifies the patient by wallet number
The portal 'link' action no longer asks the device for a name + file number.
The wallet is identified solely by its relay-verified wallet number: the clinic
attaches that number to the file ahead of time (Import from a patient app / QR
pairing), and linkWallet now just resolves the paired file (friendly 404 when
it hasn't been paired yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:56 +03:00
Khalid Abdi feffce6cbf frontend: in-dialog wallet-sync stepper for record changes
When the selected patient has a linked wallet, create/edit dialogs now show
a two-step stepper: after saving, step 2 offers to push the change to the
patient's wallet (reusing pushWalletUpdate + approval polling). Added a shared
useWalletSync hook and DialogStepper/WalletSyncStep components, wired into the
appointment, invoice, prescription, patient-edit, and scribe dialogs. Falls
back to the old close-on-save when the patient has no wallet. Added walletSync.*
keys to all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:52:36 +03:00
Khalid Abdi 35f07c508d frontend: block past dates in appointment/invoice pickers; drop stale Settings Features
- Appointment date picker disables days before today.
- Invoice issue-date picker disables past days by default, with an opt-in
  "Back-date" checkbox for recording older invoices (edit mode keeps past
  dates). Added invoices.dialog.backdate to all locales.
- Removed the inert, outdated "Features" section (patient-owned storage /
  require signed toggles) from Settings and its now-unused i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:41:58 +03:00
Khalid Abdi 143ffc39f1 frontend: fix RTL sidebar group chevron; restore header buttons
- SidebarMenuAction (the expand chevron on Patients/Pharmacy/Messages) used a
  physical right-1, so in Arabic it sat on the right over the icons. Use the
  logical end-1 so it's on the right in English and the left in Arabic, clear of
  the icons.
- Revert the earlier header change: the notification bell and collapse toggle go
  back to their original placement (no RTL column stacking, no mirrored glyph,
  notifications popover back to side="right").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:19:30 +03:00
Khalid Abdi b33561d7e1 chore: release v0.11.0
Bump root/backend/frontend to 0.11.0 and record the 0.11.0 changelog:
relay-routed Patient Portal + wallet linking, phone-ready portal QR,
appointments/invoices synced to the wallet, clinic-location reverse geocoding,
and the Arabic RTL sidebar fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:15:28 +03:00
Khalid Abdi 947623a691 frontend: relay-based Patient Portal QR + kiosk "Link wallet" option
- lib/portal.ts: getPortalLink + portalPairingUri build a temetro-portal: URI
  (relay URL + clinic signing key) instead of a localhost API URL.
- Signing settings QR now encodes that pairing URI, so a real phone can reach
  the clinic over the Temetro Network relay (fixes "server cannot be accessed").
- Portal kiosk gains a third "Link my wallet" option that shows the same QR.
- New portal.choose.wallet* / portal.wallet.* keys in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:06:04 +03:00
Khalid Abdi dd64d689c8 backend: Patient Portal over the relay + wallet linking
- services/portal.ts: clinic-scoped portal actions (clinic info, doctors,
  availability, linkWallet, conflict-checked booking, results, downloadable lab
  files) plus handlePortalRequest to dispatch a relayed portal:request.
- relay-client.ts: handle portal:request on the hub and ack the result back down
  the relay (device path identifies the patient by verified wallet number).
- patients: new nullable wallet_number column (+ migration); linking stores it,
  and walletNumberForPatient now resolves via it so pushes work after a portal
  link, not only after a permanent share.
- routes/portal.ts: GET /:clinic/link returns the relay-based pairing descriptor
  (clinic signing key + relay URL) for the QR — no more localhost-baked API URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:08:36 +03:00
Khalid Abdi 1c5e71eb39 backend: include appointments & invoices in the wallet push bundle
Appointments and invoices live in their own tables, not on the Patient
snapshot, so a clinic->wallet push previously sealed only the patient record
and the patient's appointments/invoices never reached the wallet app. Load and
attach them to the sealed bundle ({ patient, appointments, invoices, changes }).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:34:27 +03:00
Khalid Abdi fb9fa299c9 frontend: RTL sidebar arrow placement + reverse-geocode clinic location
- Arabic RTL: stack the collapse arrow/bell above the nav icons instead of
  pinning them to the opposite edge; mirror the panel-toggle glyph; flip the
  notifications popover to open toward the content side.
- "Use my current location" now reverse-geocodes (OpenStreetMap Nominatim) to
  fill address/city/country, not just latitude/longitude, with a graceful
  coordinates-only fallback. New settings.location.geoPartial key in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:32:23 +03:00
Khalid Abdi ab17978b5c feat: portal doctor booking, Arabic RTL fix, wallet portal QR (v0.10.0)
backend: public GET /api/portal/:clinic/doctors and /availability, and
thread a chosen provider into portal bookings (conflict check unchanged).

frontend: fix site-wide Arabic RTL — anchor the sidebar right for RTL and
mirror the Switch thumb. Add a Patient Portal section (open/copy/QR) to
Settings → Signing and a "Use my current location" GPS button to the clinic
location editor. New i18n keys across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:31:20 +03:00
Khalid Abdi 36461a5498 feat: patient blood type & phone + clinic location setting (v0.9.0)
Patient record:
- Add `bloodType` and `phone` to the patient model (schema, canonical types on
  both backend + frontend, zod validation). `phone` is a demographic field
  (reception can read/write); `bloodType` is clinical PHI, redacted for the
  reception role. Surface both in the record sheet, chat summary card, and the
  add/edit patient form. Migration 0033.

Clinic location:
- New org-scoped `clinic_settings` table (address/city/country + optional
  lat/long), service, and routes: GET /api/clinic/settings (any clinician) and
  PUT /api/clinic/location (owner/admin). Edited in Settings → Signing → Clinic
  location. Consumed later by the wallet app. Migration 0034.

i18n:
- Translate all new keys into every shipped locale (en/de/fr/ar/so) and document
  the "translate into every locale" rule in frontend/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:07:27 +03:00
Khalid Abdi 01dbc07e92 fix: default RELAY_URL to the hosted relay (v0.8.2)
The default http://localhost:8080 silently failed for clinics that joined
the network without setting RELAY_URL — inside Docker localhost is the
container itself, so the hub connection never reached the relay (endless
"relay unreachable" retries) and pairing QRs encoded an unreachable
localhost. Default to https://network.temetro.com so "Join Temetro Network"
works out of the box; self-hosters running their own relay still override
it. Also (re)ensure the hub is connected before pre-registering a pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:50:46 +03:00
Khalid Abdi 233ce9f854 fix: QR wallet pairing broken by multi-clinic routing (v0.8.1)
QR "scan to connect" pairing has no wallet number, so nothing registered
the request with the relay and the scanning device's response was rejected
("unknown or expired request"). The backend now pre-registers the pairing
request over the relay's new hub:expect event on POST /pair, and
re-registers still-pending requests on hub (re)connect so routing survives
a relay restart. /pair now requires the clinic to have joined the network
(clear 409, surfaced in the import dialog, localized in all five langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:16:33 +03:00
Khalid Abdi 99aa534e88 feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub
namespace by signing a challenge with its own Ed25519 clinic signing key
(a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes
every device response back to only the clinic that originated the request
(keyed by requestId) — so clinics never see each other's traffic.

Backend:
- clinic_signing_keys.network_enabled + GET/PUT /api/signing/network
  (owner/admin) to join/leave the network.
- relay-client keeps one authenticated hub connection per network-enabled
  org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/
  sendToWallet take orgId; offline flush is org-scoped.
- Wallet import/push return 409 until a clinic joins.
- RELAY_TOKEN is now optional/legacy (open relay needs no shared secret).

Frontend:
- "Join Temetro Network" toggle in Settings → Signing, localized in all
  five languages (en, fr, de, so, ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:30:19 +03:00
Khalid Abdi ef76afc3ca feat: route wallet traffic through the Temetro Network relay (v0.7.0)
Devices no longer connect to the backend directly. The /wallet Socket.io
namespace is removed from realtime.ts; a new services/relay-client.ts connects
to the standalone Temetro Network relay's /hub namespace (RELAY_TOKEN-auth),
emitToWallet delegates to its sendToWallet, and device responses + wallet:online
replay are handled there via the same wallet-share/wallet-updates services.

- Add RELAY_URL + RELAY_TOKEN env (env.ts, .env.example, docker-compose.yml);
  the wallet-import QR (resolveRelayUrl) now points at RELAY_URL.
- Add socket.io-client dependency.
- Document the Temetro Network folder/service in root + backend CLAUDE.md.
- Bump root/backend/frontend to 0.7.0; CHANGELOG entry.

The relay service itself lives in github.com/temetro/temetro-network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:04:23 +03:00
Khalid Abdi d79f7f7c06 chore(release): v0.6.0
Read-only FHIR R4 server at /fhir with per-clinic API keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:34:24 +03:00
Khalid Abdi 0d2494d67a feat: read-only FHIR R4 server (share records over /fhir)
Expose temetro's own records as a read-only FHIR R4 server at /fhir,
authenticated with per-clinic API keys (tmf_… bearer tokens, SHA-256
hashed, shown once). Serves Patient, Observation (labs + vitals),
AllergyIntolerance, Condition, MedicationRequest, Encounter and
Appointment as text-only CodeableConcepts (temetro stores free-text
clinical values); CapabilityStatement at /fhir/metadata (unauth).
Searchset Bundles with _count/_offset pagination and self/next/prev
links; every request is org-scoped and written to the activity log.
Keys are created/revoked under Settings → Integrations (owner/admin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:27:32 +03:00
Khalid Abdi bb536ba6da feat: clinic→wallet record-update push
A clinician can push an updated record to a wallet-linked patient (permanent
share). The snapshot is signed with the clinic Ed25519 key and sealed to the
wallet's X25519 key — derived from its Ed25519 wallet number via the birational
map, verified byte-for-byte against the wallet's own derivation. Stored pending,
delivered over the /wallet relay live and on the wallet's next authenticated
connect (offline catch-up). The patient approves/denies in-app; the wallet signs
its decision, the backend verifies it, and the record is replaced only on
approval. Wallet pins the clinic key (TOFU) and warns on change.

Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper,
POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id,
wallet:update-request / wallet:update-response relay events.
Frontend: "Push to wallet" dialog with live status, wallet-link gating on the
patient sheet, "Sent updates" list under Settings → Signing, walletPush /
walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:57:38 +03:00
Khalid Abdi b29fdff1cb feat: ambient AI visit scribe (record/paste → reviewed SOAP note)
Record a clinician↔patient visit (or paste a transcript) on the patient
sheet; the backend transcribes it (OpenAI Whisper / Gemini), de-identifies
the transcript + context through Veil, and drafts a structured SOAP note
the clinician reviews and edits before saving — the same write-approval
gate as the chat agent.

Backend: POST /api/scribe/{transcribe,draft,save} (routes/scribe.ts,
services/ai/transcribe.ts), veil.redactText() free-text redactor,
appendEncounter service, audio MIME types on attachments. Gated by
patient:write + the clinic AI policy (reception/disabled-AI excluded).
Frontend: ScribeDialog + lib/scribe.ts, "Record visit" on the patient
detail, gated by clinical access + AI availability. New `scribe` locale
namespace across all five languages. Bumps to v0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:33:30 +03:00
Khalid Abdi d237504af9 frontend: add Somali, Arabic (RTL) & German languages
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside
en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support:

- config.ts registers the locales and exports a `dirFor` helper; an inline
  <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL
  flash), and i18n-provider keeps them in sync on language change.
- ~160 physical direction utilities converted to logical (ms/me/ps/pe/
  start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180;
  chat-bubble align variants fixed to logical.
- IBM Plex Sans Arabic appended to the sans/heading font stacks for
  per-character Arabic fallback.
- Language persists to the backend user_settings and re-applies on sign-in so it
  roams across devices (localStorage stays the offline source of truth).
- New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder
  parity and Arabic CLDR plural completeness.

Bump to 0.3.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:03:13 +03:00
Khalid Abdi 46c32b432c chore(release): v0.2.5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:54:47 +03:00
Khalid Abdi 5bbfe551fc release workflow: publish multi-arch images; settings: confirm language switch
- release.yml: add QEMU + platforms: linux/amd64,linux/arm64 to both
  build-push steps so the published images work on Apple Silicon (fixes
  'no matching manifest for linux/arm64/v8' on docker compose pull).
- Settings -> Profile language picker is now a select that opens a
  confirmation dialog before applying, instead of switching instantly.
- CLAUDE.md: require a dated docs changelog entry for every release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:54:47 +03:00
Khalid Abdi 7838dd68a5 chore(release): v0.2.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:52 +03:00
Khalid Abdi 06810f861e backend: make docker compose host ports configurable
Mirror the existing POSTGRES_PORT override for the backend, frontend and adminer
host ports (BACKEND_PORT / FRONTEND_PORT / ADMINER_PORT) so a port clash on
`docker compose up -d` can be fixed via .env without editing the compose file.
Documented in .env.example and the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi 2bb03633ff backend: detect updates from Docker Hub + add a Check for updates button
GET /api/version now reads the latest version from Docker Hub image tags (the
actual update channel clinics pull), falling back to the GitHub release if
Docker Hub is unreachable, with a shorter 1h cache and a `?refresh=1` bypass.
Settings → About & updates gains a "Check for updates" button that forces a
fresh, cache-bypassing lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi dc5f55f87d frontend: paginate the Activity and Invoices pages
Extract the Patients pagination into a reusable ListPagination component
(components/ui/list-pagination.tsx, carrying the pageWindow helper) and use it
on the Activity feed and the Invoices list (10/page, search resets to page 1).
Patients is refactored onto the same component, removing the duplicated block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:18 +03:00
Khalid Abdi a4970df334 frontend: add a language switcher to Profile settings
A Language section in the Profile panel offers English / Français, wired to
i18n.changeLanguage (persisted to localStorage via the detector cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:01 +03:00
Khalid Abdi 6c074b54f3 frontend: add French translation and language i18n keys
Register `fr` in the i18n config (resources + supportedLngs) and add a full
French translation (locales/fr/translation.json) with exact key parity to
English (1661 keys; placeholders and plural suffixes preserved). Also adds the
shared `common.pagination.*` keys, `settings.version.checkNow`, and
`settings.profile.language.*`, and drops the now-unused `patients.pagination.*`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:00:01 +03:00
Khalid Abdi f1c9f8af55 chore(release): v0.2.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:37:23 +03:00
Khalid Abdi 1c65f72ccf backend: render patient card from searchPatients on a unique match
"Show me <name>'s medical record" relied on the model chaining
searchPatients → getPatient, but Gemini Flash often calls searchPatients
then emits the canned closing line ("Here's the record.") without the
second tool call, so no data-patientCard part is ever written and no card
renders. (The prior Gemini fix only covered the empty-schema list tools.)

searchPatients now writes the record card (data-patientCard, or
data-recordGraph in graph mode) and a source directly when EXACTLY ONE
patient matches — mirroring getPatient — so the common name-lookup flow no
longer depends on a second tool call. Multiple/zero matches keep returning
the disambiguation list. The tool description and system prompt tell the
model the card is already shown on a unique match so it doesn't double-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:36:49 +03:00
Khalid Abdi f12285b8c6 chore(release): v0.2.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:53:13 +03:00
Khalid Abdi 75910f7fb0 backend: fix AI chat cards not rendering on Gemini (empty tool schemas)
The card-emitting chat tools (listAppointments, listTasks,
listPrescriptions, getClinicInfo, getAnalytics, listInventory) used an
empty `z.object({})` parameter schema. Google Gemini can't emit a
function call for a tool whose JSON schema has no properties — it prints
the call as `tool_code` text instead of invoking it — so the tool never
ran and the frontend never received the data part to render a card.

Give those tools a shared `emptyToolArgs` schema with one optional,
ignored field so the schema is non-empty and Gemini calls them. execute
bodies are unchanged; other providers ignore the extra field. This is the
same fix already documented for previewImport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:52:22 +03:00
Khalid Abdi 3767c1689b chore(release): v0.2.1
Bump root, backend, and frontend to 0.2.1 and add the 0.2.1 changelog section.
Patch release: pagination styling, patient-sheet header reflow, message
avatars, and CHANGELOG-based GitHub Release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:22 +03:00
Khalid Abdi edf42e9741 ci: publish the CHANGELOG section as the GitHub Release body
The release workflow only set generate_release_notes, so releases carried just
the auto "Full Changelog" link. Extract this version's section from CHANGELOG.md
into release-notes.md and pass it via body_path (the generated link is still
appended), falling back to a generic line when there's no entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:12 +03:00
Khalid Abdi 31adbab87b frontend: fix pagination styling, patient-sheet header, message avatars
- Patients pagination: render the prev/next + page-number controls as COSS
  Buttons inside the Pagination structure. PaginationLink drops its
  buttonVariants styling when given a `render` prop, so the controls were
  unstyled and wrapping; using Button restores proper pill/number buttons.
- Patient detail sheet: move the action buttons (Download summary / Transfer /
  Edit / Delete) to their own wrapping row beneath the identity block so the
  patient name is no longer truncated on the narrow sheet.
- Messages thread: add sender/recipient avatars (MessageAvatar) at the bottom
  of each message group, with a spacer to keep stacked bubbles aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:16:12 +03:00
Khalid Abdi 15bf5653b4 chore(release): v0.2.0
Bump root, backend, and frontend to 0.2.0 and move the changelog Unreleased
notes under a dated 0.2.0 heading. Adds Patients pagination, per-patient record
history, patient summary PDF export, the messages bubble/attachment UI, the AI
import approval-card fix, and the Docker network-address fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:36:44 +03:00
Khalid Abdi 8ba7256105 chore: require release-after-push in CLAUDE.md; update changelog
Document that finishing a unit of work and pushing must always be followed by
a version bump (root + backend + frontend package.json) and a Docker Hub image
publish — temetro ships as prebuilt images, so an un-released change never
reaches a self-hosted clinic. Record this session's changes in CHANGELOG.md.
(The two stray root logo PNGs were removed in an earlier commit this session.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:34:20 +03:00
Khalid Abdi 6237cc29d0 frontend: one-time AI setup notice above the chat input
Show a single, dismissible heads-up on a fresh chat when no AI provider (an
API key or a local Ollama endpoint) is configured, linking to Settings → AI.
It only renders on the empty state, so it clears itself once the first message
is sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:43 +03:00
Khalid Abdi 8cabd17cdd frontend: rebuild the messages thread with Message/Bubble/Attachment
Compose the conversation thread from the shadcn Message, Bubble, and
Attachment components (Base UI under the hood), keeping COSS semantic colour
tokens — outgoing bubbles use the primary variant, incoming use muted, and
shared files/appointments/password-resets render as Attachment cards. Day
separators, sender grouping, and timestamps are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:31 +03:00
Khalid Abdi 3ee00fcf06 feat: per-patient record history + summary PDF export
Add GET /api/activity/patient/:fileNumber (every audited change on one chart,
newest first, readable by any clinic member) and surface it as a Record
history timeline in the patient detail sheet. Add a Download summary action
that builds a clean, printable one-page clinical summary in the browser
(Save as PDF) — no PDF dependency, nothing leaves the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:31 +03:00
Khalid Abdi 869038477a frontend: paginate the Patients table and colour status badges
Add the COSS Pagination primitive and page the patients list at 10 rows/page
(search resets to page 1; the page is clamped at render so a shrinking list
never strands you past the last page). Replace the flat "secondary" status
badge with semantic colours: active → success, inpatient → info, discharged →
outline. Includes the i18n keys for pagination, the AI setup notice, and the
patient record-history / PDF export features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:18 +03:00
Khalid Abdi 8309e1e82e fix: don't show a bogus container IP for the network address under Docker
/api/network reported the container's bridge IP (172.x) when running in
Docker, which surfaced as a broken/"Error" value in Settings → About &
updates. Skip container-internal interfaces (detected via /.dockerenv) so the
endpoint returns no address inside a container, and guard the panel against an
unexpected response shape — both paths fall back to the helpful
"open via the server's IP" hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:08 +03:00
Khalid Abdi b2ac27dda7 backend: render AI import approval card instead of raw tool_code
Give previewImport a concrete object schema (was z.array(z.unknown())) so
Google Gemini can emit a real function call — an array-of-unknown serializes
to an empty JSON schema, which made Gemini print the call as a `tool_code`
text block instead of invoking the tool. Validation stays lenient in
execute() via patientInputSchema. Also strengthen the system prompt: never
print tool calls/JSON or re-list record fields; keep prose to one sentence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:32:58 +03:00
Khalid Abdi c82ba4b33e infra/docs: publish images under the khalidxv Docker Hub namespace
Retarget the release workflow, docker-compose image refs, and docs from the
placeholder temetro namespace to khalidxv (GitHub repo refs unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:40:51 +03:00
Khalid Abdi 130f90bf6a infra/docs: Docker Hub release pipeline, versioning, README overhaul
Reference prebuilt temetro/temetro-{backend,frontend} images in docker-compose
(with a build fallback) and stop baking the frontend API URL. Add a
tag-triggered GitHub Actions release workflow that pushes both images and cuts
a GitHub Release, plus RELEASING.md, CHANGELOG.md, and a root version. Overhaul
the root and frontend READMEs (screenshot, features, prebuilt-image quickstart,
LAN access, updating).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:34:44 +03:00
Khalid Abdi 403e0e38e9 frontend: runtime backend URL (LAN), voice dictation, version + update UI
Resolve the backend URL from the current host at runtime (lib/backend-url.ts)
so one build works on localhost and any clinic LAN IP without a rebuild; used
by the API client, auth client, and socket. Wire the AI-chat mic to the Web
Speech API with graceful fallback. Add a Settings "About & updates" panel
(current/latest version, update command, shareable network address) and an
optional dismissible update banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:34:30 +03:00
Khalid Abdi 2454bb4c2b backend: version + network endpoints, LAN-friendly CORS/origins
Add public GET /api/version (current version + GitHub-release update check,
cached, fail-soft) and GET /api/network (detected LAN addresses). Accept
localhost/private-LAN origins in CORS and Better Auth trusted origins via a
shared isAllowedOrigin helper, so staff can reach the app over the clinic
network. The seed-demo.ts fake test data is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:34:19 +03:00
Khalid Abdi 6bd81bd7dd docs: wallet app UI is now HeroUI Native, not @expo/ui
The sibling patient wallet app was redesigned to build its UI with HeroUI
Native (Uniwind/Tailwind); only the tab bar stays expo-router NativeTabs.
Update the "Patient wallet app" note to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:24:01 +03:00
Khalid Abdi a68b7f2573 fix(backend): make tunnel relay reachable before baking it into QR
Two changes so off-network wallet scanning actually works:

- Force the cloudflared edge connection over http2 (TCP/443) instead of
  QUIC (UDP/7844) in both the Docker tunnel and the dev-tunnel script.
  QUIC is blocked on many networks/Docker setups, which left the tunnel
  stuck "Failed to dial a quic connection" and the QR pointing at a dead
  URL — the root cause of "must be on the same network" failures.
- Gate the discovered quick-tunnel URL on real end-to-end reachability:
  poll the tunnel's own /health until it answers (Cloudflare 1033 clears
  in ~30s) before publishing it, and have /pair await that discovery so
  the QR never carries a not-yet-live URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:56:29 +03:00
Khalid Abdi 014b4ddf8f feat(backend): one-command Dockerized cloudflared tunnel
Add `npm run docker:tunnel` (docker-compose.tunnel.yml overlay): a
cloudflared sidecar opens a public quick tunnel to the backend, and the
backend auto-discovers its https://…trycloudflare.com URL from cloudflared's
metrics endpoint (services/relay-url.ts) and bakes it into the wallet-import
QR — so a phone on any network can scan, with no install/account/PUBLIC_RELAY_URL.

PUBLIC_RELAY_URL still wins when set. Verified end-to-end: the backend logs
the discovered tunnel URL on startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:13:45 +03:00
Khalid Abdi f10d01546b feat(backend): public relay for off-network wallet scanning
Scan-to-connect only worked on the same Wi-Fi because the QR carried a LAN
relay URL. Give the relay a public address two ways:

- `npm run dev:tunnel` (scripts/dev-tunnel.mjs) opens a cloudflared tunnel
  to localhost:4000 and starts the backend with PUBLIC_RELAY_URL set to the
  public https URL, so the QR carries it and a phone on any network connects.
- Deploy configs for Fly.io (fly.toml), Render (render.yaml), and Railway
  (railway.json), all building the existing Dockerfile.

Also harden resolveRelayUrl to honor x-forwarded-proto so the derived QR URL
is https behind a TLS proxy (iOS ATS requires wss).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:01:54 +03:00
Khalid Abdi 4e60361f77 fix: use phone-reachable relay URL in wallet-import QR
The "Import from a patient app → QR" code baked the web app's own
API_BASE_URL (typically http://localhost:4000) into the pairing URI, so a
real phone could never reach the relay and "Scan to connect" silently
stalled. Use the backend's server-resolved, device-reachable relay URL
instead (PUBLIC_RELAY_URL, else the request host), surface it on the
WalletPairing type, and document PUBLIC_RELAY_URL in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:39:08 +03:00
Khalid Abdi 7a359d4911 feat: QR pairing for wallet import (backend + website)
- Backend: nullable wallet_number on wallet_share_requests (migration 0029);
  createPairingRequest + POST /api/patients/wallet/pair; applyShareResponse
  binds the authenticated wallet on QR (pairing) requests
- Frontend: Import dialog gains a "Show QR" mode rendering a temetro-pair QR
  (react-qr-code) the patient scans; requestWalletPairing helper; i18n keys

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:31:51 +03:00
Khalid Abdi 2d47abcc42 feat: patient wallet — real Signing, encrypted share relay, import-from-app
Backend
- clinic Ed25519 signing key (services/signing.ts, routes/signing.ts,
  clinic_signing_keys table) — Settings → Signing is now real
- @noble wallet-crypto (lib/wallet-crypto.ts): ed25519 identity, base58check
  wallet numbers, sealed-box (x25519 + xchacha20poly1305)
- /wallet Socket.io relay namespace (challenge-signed device auth) forwarding
  only ciphertext; emitToWallet helper
- import-from-app flow (routes/patients-wallet.ts, services/wallet-share.ts):
  request-share → patient approval → decrypt + verify → review draft → commit
- temporary shares: patients.share_expires_at + 5-min auto-delete sweep; revoke

Frontend
- SigningPanel wired to live key/fingerprint/rotate + shared-records list
- "Import from a patient app" dialog (lib/signing.ts, import-from-wallet-dialog)
  reusing the draft-review path; temporary badge on the patient list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:19:57 +03:00
Khalid Abdi b74d5d2d05 feat: username reset, system-message UX, meetings polish, sidebar identity
- auth: forgot-password now has Email/Username tabs; new public
  /api/auth-helpers/reset-by-username resolves a username and hands off to the
  existing reset flow (real email or admin-notify fallback)
- messages: conversations expose isSystem; System notices are read-only (no
  composer, no call button) and styled distinctly (shield icon + badge)
- meetings: compact, larger control bar; self tile shows real initials and an
  avatar (not black) when the camera is off; delete-room UI with confirm
- sidebar: username-only accounts show @username instead of a synthetic email

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:39:48 +03:00
Khalid Abdi 90e6ec4cc0 feat: email provider, admin password reset, portal new-patient, chat pill
Chat: history pill now shows a History icon + a Start-new-chat (SquarePen)
button; removed the duplicate chat-history list from the sidebar.

Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in
Settings → Developers, with encrypted API key and a Send-test action. sendEmail
dispatches via the chosen provider (REST via fetch; SMTP via nodemailer).

Forgot password with no provider: alert the clinic admin(s) via a "System"
message card in Messages + a bell notification (seeded system user + per-clinic
System conversation); clicking deep-links to /settings?tab=careTeam&member=<id>.
Admins can set a member's password directly from the employee dialog
(PATCH /api/staff/:id/password via Better Auth's internal context — no admin
plugin needed).

Patient Portal: "New patient" booking path registers a demographics-only patient
then books; bookings reject double-booked slots (409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:52:55 +03:00
Khalid Abdi 516de6ad60 portal: public Patient Portal kiosk (self-service booking + results)
New chrome-less (portal) route group with /portal/[clinic], a card-style choice
of "Book an appointment" vs "View my results", and step flows wired to a new
public backend router (src/routes/portal.ts):
- GET  /api/portal/:clinic            -> clinic name
- POST /api/portal/:clinic/appointments  (verifies name+file#, books a confirmed
  appointment that appears on the doctor's Appointments page)
- GET  /api/portal/:clinic/results    (minimal, safe status — no clinical values)

Excluded /portal from the auth proxy redirect so the kiosk loads without a
session. PHI exposure is intentionally minimal (see docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:49:39 +03:00
Khalid Abdi e656eae362 settings: Records import & export for temetro data
Backend: GET /api/settings/records/export downloads the clinic's full patient
archive as JSON; POST /api/settings/records/import creates new patients (skips
existing file numbers, drops cross-clinic provider links). Both admin-gated.

Frontend: Records settings now has a working Export button and an Import flow
(choose file -> preview count -> apply) wired to the new endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:44:37 +03:00
Khalid Abdi bbc6869745 tasks: assign a task to a specific person
Backend: add tasks.assignee_user_id (nullable, fk user) + migration; persist
it through create/update and surface tasks to the assigned person in listTasks.

Frontend: New Task dialog gains a three-way Assignee control
(Myself / Department / Person) with a provider picker from /api/staff/providers;
task card + detail show the assignee's name when set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:40:56 +03:00
Khalid Abdi 1d9b1b1d22 frontend: chat/meetings/messages/settings UX fixes
- settings: keep tab nav on its own row so "Developers" no longer wraps
- chat: show the "Veil active" chip once per conversation, not every turn
- chat: record cards only offer "Click for more" when they have detail
- chat: chat-history panel (pill + sheet) top-left of the AI chat with
  "Start new chat"; rename sidebar "New chat" -> "Ask temetro" (Sparkles)
- meetings: disable past dates, add an Upcoming Meetings list, and use the
  Empty component for empty days; scheduler can be pre-targeted via ?with
- messages: add a call button left of each inbox row -> Meetings (?with)
- toast: add a dismiss (x) button; call invites now ring 30s with "Accept"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:34:48 +03:00
Khalid Abdi 913a217e1d feat(meetings): live invite, scheduling calendar, Discord-style redesign
- Live invite: ring a clinic member into a room (socket call:invite + a bell
  notification); the invitee gets a toast with a Join action. Notifications of
  type "meeting" deep-link to /messages/meetings?room=, which auto-joins.
- Scheduling: new scheduled_meetings table + /api/meetings/events (list mine,
  create, delete); a Calendar tab on the Meetings page with a month picker
  (meeting-day dots), the day's agenda, and a Schedule-meeting dialog
  (title/date/time/participants).
- Redesign: rounded control bar with tooltips (mic/cam/screen/invite + separated
  red Leave), speaking ring on tiles (Web Audio), and live room-occupancy counts
  via call:presence broadcasts. Migration 0025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:07:41 +03:00
Khalid Abdi e841cb56e1 fix(chat,analysis,sidebar): compact cards, sparse citations, range + active state
- Chat patient cards are now compact previews (header + key stat + "Click for
  more"); cards size to content (items-start) instead of stretching to the tallest;
  Edit record moved into the summary detail dialog
- AI citations render once per source per message (collapses per-word spam) and the
  prompt now asks the model to cite sparingly (once per paragraph, not per list item)
- Sidebar sub-items use exact-match active state so Meetings no longer lights Inbox
- Analysis defaults to All Time, drops the 12m option, and clamps the chart window
  to >=2 points so 30d/Today render instead of collapsing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:57:31 +03:00
Khalid Abdi c88b674196 feat(meetings): Discord-style staff voice/video calls under Messages
Backend: meeting_rooms table + /api/meetings (list/create/delete, org-scoped),
and WebRTC mesh signaling over the existing authed Socket.io (call:join with a
≤4 cap and org authorization, call:signal relay, peer-joined/left, disconnect
cleanup). Migration 0024.

Frontend: Messages nav is now expandable (Inbox + Meetings); new
/messages/meetings page with a room list and a Discord-style call UI — a
useWebRtcMesh hook (camera/mic, screen share via replaceTrack, ≤4 mesh) and a
tile grid with a mic/camera/screen/leave control bar. New lib/meetings + i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:48:26 +03:00
Khalid Abdi 0fa2802723 feat(ai): inline source citations in the AI chat
Retrieval tools now register a PHI-free sourceId per record/list and stream a
data-source part with the real clinician-facing title; the system prompt asks
the model to cite facts inline as [[src:id]]. The chat renders those markers as
numbered inline citation chips (PreviewCard hover) via a Streamdown link
override, with a Sources footer fallback when the model omits markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:41:48 +03:00
Khalid Abdi 2f36875d37 backend: add per-clinic staff specialty (Care Team -> patient sheet)
New staff_profile table (org + user, unique) holding a clinician's clinical
specialty. GET /api/staff and /api/staff/providers now include specialty; new
PATCH /api/staff/:userId (member:update) upserts it. Migration 0023.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:28:52 +03:00
Khalid Abdi eb94c1549a frontend: fix onboarding bounce, mobile sidebar btn, notif deep-links, analysis header
- Fix clinic onboarding loop: refresh session after setActive before navigating,
  surface setActive errors, and guard AppAuthGuard's onboarding redirect race (#1)
- Add a mobile-only floating top-right SidebarTrigger so the sidebar is reachable
  when the offcanvas sidebar is closed on phones (#5)
- Make notifications clickable: navigate to the source (conversation/patient) and
  mark read; MessagesView/PatientsView honor ?conversation= / ?file= deep links (#6)
- Analysis page: add an Overview header with a time-range segmented control and a
  Customize popover that shows/hides sections; range slices month-based charts (#10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:21:05 +03:00
Khalid Abdi dfab71492c fix(graph): stop hover flicker and theme the controls
Hover state fed the nodes/edges useMemos, so every hover recreated both
arrays — React Flow re-measured the dimensionless nodes and the hovered
node's scale transform shifted it out from under the pointer, causing a
rapid dark↔light flicker. Drive hover focus through React context instead
so the nodes/edges props stay referentially stable (custom memoized node +
edge read the hovered id), and drop the scale transform. Theme the React
Flow controls via colorMode from next-themes so the zoom box matches the
active theme instead of rendering white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:45:09 +03:00
Khalid Abdi 1c52dcaf84 fix(chat): graph mode renders the record graph, not cards
In Graph mode, a natural-language request ("graph for John Smith") went
through the getPatient tool, which always emitted record cards — so the
clinician got Summary/Vitals cards instead of a graph (the mode prompt
even wrongly claimed the graph rendered automatically). Thread the
composer mode into the chat tools and have getPatient emit
data-recordGraph in graph mode (matching the client-side /patient
fast-path), with the cards kept for chat/analysis modes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:45:09 +03:00
Khalidabdi1 5cbc7411c0 Merge pull request #7 from temetro/feature/clinical-fixes-integrations
Feature/clinical fixes integrations
2026-06-18 21:14:43 +03:00
Khalid Abdi 7bb1ee39ab fix: lock AI proposal cards after Add to prevent duplicate commits
Approval state lived only in each card's local React state, so after
committing (or after reloading a saved conversation) the Add / Review &
add / Import buttons reappeared active and the same records could be
added again. Persist the resolution onto the message data part via a
chat-panel handler (setMessages), and initialize each card's status from
data.resolved so committed/discarded proposals stay locked across
re-render and history reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:09:09 +03:00
Khalid Abdi f9615fa74e feat: edit parsed records before importing
The import preview only showed counts + skipped-row errors with no way to
fix anything. Now every parsed record is editable before import:

- backend: shared validatePatientImport() (used by the previewImport tool)
  + POST /api/ai/import/validate for dry-run re-validation; the import
  preview payload now carries the original records (and the source record on
  each invalid row) so the UI can edit them.
- frontend: the import card gets a "Review & edit" dialog listing every
  record with a ready / needs-fixing badge; opening one reuses the full
  PatientFormDialog in a new non-persisting review mode (editable file
  number) and re-validates on save, so skipped rows can be fixed and
  included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:03:33 +03:00
Khalid Abdi 064aa22099 backend: AI normalizes imports itself + never replies blank
- Migration prompt now tells the agent to map/normalize an uploaded export
  into temetro's shape itself (gender words, non-numeric IDs, delimited
  allergy/med/problem lists, per-visit encounters) and to fix-and-re-preview
  skipped rows rather than telling the clinician to reformat the file.
- Guarantee a non-empty reply: when the model ends on a tool call with no
  text, ask it to summarize (external+Veil path) and fall back to a generic
  line; the streaming path appends the same fallback. Raise the step cap to 8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:52:33 +03:00
Khalid Abdi 2c9cec0112 backend: tolerant patient import validation
Real-world exports were rejected wholesale (file number must be digits,
sex must be M/F, every allergy/med/problem/encounter a full object), so
AI imports skipped everything. Normalize at the schema edge instead:
strip non-digits from the file number, map gender words to M/F, accept a
bare string for allergies/medications/problems (filling sensible
defaults), and default missing encounter fields (type → "Visit"). The
manual patient form shares this schema and benefits too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:52:33 +03:00
Khalid Abdi 43eaccb97e feat: HL7/FHIR, e-prescribing & insurance claims integrations
Real, standards-compliant integration clients that the clinic points at
its own (sandbox or production) endpoints — no mock data.

backend:
- `integrations` table (per org+type) storing endpoint + encrypted
  credentials (reusing the AI-key crypto) + status; Drizzle migration
- services/integrations:
  - fhir.ts — FHIR R4 REST client (pull lab Observations → patient
    record), HL7 v2 ORU parsing, capability-statement connection test
  - eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit
  - claims.ts — X12 837P claim generation + 835 remittance parsing
- `/api/integrations` route: config GET/PUT (owner/admin), connection
  test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions,
  RBAC-gated (lab/patient, prescription, invoice)

frontend:
- lib/integrations.ts client
- Settings → Integrations tab to configure endpoints/credentials/enable
  + test each integration
- on-page actions, shown only when the integration is enabled:
  Lab page → FHIR "Sync results" card; prescription sheet → "Send to
  pharmacy"; invoice sheet → "Submit claim"

Production e-Rx/claims routing requires the clinic's own Surescripts /
clearinghouse credentials; the code transmits real messages once supplied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:15:00 +03:00
Khalid Abdi b1abb29108 feat: patient & lab file attachments (real backend storage)
Add a real file-storage layer to the backend and wire upload UI into the
frontend.

backend:
- new `attachments` table (org-scoped, links to a patient file number and
  optionally a lab result) + Drizzle migration
- `/api/attachments` route: upload (multer → disk under UPLOAD_DIR),
  list, stream/download, delete; gated by patient:write OR lab:write via
  a new requireAnyPermission helper so lab staff can attach analyses
- UPLOAD_DIR env (default ./uploads) + a persistent docker volume

frontend:
- lib/attachments.ts client (multipart upload, list, delete, preview URL)
- staged file picker in the patient Add/Edit dialog (uploaded after save)
  and the lab Add-result dialog (linked to the result)
- a Files section in the patient sheet that lists attachments and opens
  them in a preview dialog (images inline, others via download)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:04:03 +03:00
Khalid Abdi 67cafdac3d frontend: make AI kill-switch a true clinic-wide master switch
The "Enable AI assistant" toggle already disabled the AI for everyone
(including owners/admins) at the policy layer, but the copy read as an
employee-only control and the Analysis surface stayed visible. Clarify
that the master switch covers admins too, and close the gaps: hide the
Analysis nav/command entry and redirect /analysis (alongside the chat
home) to /patients when AI is disabled. The employee-only toggle remains
the secondary option that keeps AI for owners/admins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:53:31 +03:00
Khalid Abdi 9a913147fb frontend: make AI proposal cards editable and de-densify them
The single proposal card crammed every proposed record into comma-joined
summary lines — an inventory import became an unreadable wall of text.
Render inventory/invoice records as a compact scrollable item list, and
add an Edit button (single card + per-row in the batch review) that opens
a per-kind edit dialog so the clinician can adjust the data — whole record
or individual items — before it is committed.

New RecordEditDialog is schema-driven (EDIT_SCHEMAS) per action kind,
including add/remove rows for invoice line items and inventory items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:51:24 +03:00
Khalid Abdi a06c8c739b frontend: Obsidian-style record graph + pop-out dialog + records list
The record graph was a cramped React Flow box wedged inside a sheet
section. Redesign it Obsidian-style — round nodes with the label
underneath, a soft glow, and a hover-focus that highlights a node plus
its neighbours while dimming the rest.

Move it out of the inline section: the sheet now shows an "Open graph"
button (closes the sheet, opens the graph in a large dialog) and a
clickable list of the patient's records (problems + visits); clicking a
record opens a detail dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:47:16 +03:00
Khalid Abdi 5975e9e21a frontend: show lab permission row in care-team employee dialog
CLINICAL_RESOURCES includes "lab" but the employee detail dialog only
mapped patient/appointment/prescription/task, so the lab row rendered as
the raw i18n key with no icon. Add a FlaskConical icon and a
"Lab results" label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:42:15 +03:00
Khalidabdi1 e43409ac14 Merge pull request #6 from temetro/chore/community-health
chore: add community health files
2026-06-17 20:56:40 +03:00
Khalid Abdi be3968ce64 chore: add community health files
Add MIT LICENSE (root, backend, frontend), CONTRIBUTING.md, CODE_OF_CONDUCT.md,
SECURITY.md, GitHub issue templates (bug/feature), and PR template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:50:09 +03:00
Khalidabdi1 f38f75617a Merge pull request #5 from temetro/feature/clinical-fixes-and-chat
Clinical UX batch: record graph, AI modes, payments, deletes
2026-06-17 20:00:52 +03:00
Khalidabdi1 cea781493f Merge pull request #4 from temetro/feature/clinical-fixes-and-chat
Feature/clinical fixes and chat
2026-06-16 20:49:47 +03:00
Khalidabdi1 559667e380 Merge pull request #3 from temetro/feature/clinical-fixes-and-chat
Feature/clinical fixes and chat
2026-06-15 19:10:53 +03:00
Khalidabdi1 ed4106ed14 Merge pull request #2 from temetro/feature/clinical-fixes-and-chat
Clinical fixes + AI chat: invoices, appointments UX, analytics/earnings, BKLIT charts, live data, chat history
2026-06-14 22:47:28 +03:00
317 changed files with 100679 additions and 1575 deletions
+199
View File
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an approval
from the project maintainers before using the Apache License.
Copyright 2025 NextUI Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+140
View File
@@ -0,0 +1,140 @@
---
name: heroui-migration
description: "HeroUI v2 to v3 migration guide for agents. Use when migrating HeroUI v2 apps to v3, upgrading components, or accessing migration documentation. Keywords: HeroUI migration, v2 to v3, migration guide, upgrade HeroUI."
metadata:
author: heroui
version: "2.0.0"
status: preview
---
# HeroUI v2 to v3 Migration Guide
This skill helps agents migrate HeroUI v2 applications to v3. HeroUI v3 introduces breaking changes: compound components, no Provider, Tailwind v4, and removed hooks.
---
## Installation
```bash
curl -fsSL https://heroui.com/install | bash -s heroui-migration
```
---
## CRITICAL: Always Fetch Migration Docs Before Applying
**Do NOT assume v2 patterns work in v3.** Always fetch migration guides before implementing changes.
### Key v2 → v3 Changes
| Feature | v2 (Migrate From) | v3 (Migrate To) |
| ------------- | -------------------------- | -------------------------------------- |
| Provider | `<HeroUIProvider>` required | **No Provider needed** |
| Component API | Flat props: `<Card title="x">` | Compound: `<Card><Card.Header>` |
| Event handlers | `onClick` | `onPress` |
| Styling | `classNames` prop | `className` prop |
| Hooks | `useSwitch`, `useDisclosure`, etc. | Compound components, `useOverlayState` |
| Packages | `@heroui/system`, `@heroui/theme` | `@heroui/react`, `@heroui/styles` |
---
## Accessing Migration Documentation
**For migration details, examples, and step-by-step guides, always fetch documentation:**
### Using Scripts
```bash
# List all available component migration guides
node scripts/list_migration_guides.mjs
# Get main migration workflow (full or incremental)
node scripts/get_migration_guide.mjs full
node scripts/get_migration_guide.mjs incremental
# Get component-specific migration guides
node scripts/get_component_migration_guides.mjs button
node scripts/get_component_migration_guides.mjs button card modal
# Get styling migration guide
node scripts/get_styling_migration_guide.mjs
# Get hooks migration guide
node scripts/get_hooks_migration_guide.mjs
```
### Direct URLs
Migration docs (preview): `https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration/{filename}`
Examples:
- Full migration: `.../agent-guide-full.mdx`
- Incremental: `.../agent-guide-incremental.mdx`
- Button: `.../button.mdx`
- Styling: `.../styling.mdx`
- Hooks: `.../hooks.mdx`
Override base URL with `HEROUI_MIGRATION_DOCS_BASE` when docs are merged to production.
### MCP Alternative
When using Cursor or other MCP clients, configure the Migration MCP server for tool-based access:
```json
{
"mcpServers": {
"heroui-migration": {
"url": "https://migration-mcp.heroui.com"
}
}
}
```
---
## Migration Strategies
### Full Migration
- Best for: Projects that can dedicate focused time; teams comfortable with temporarily broken code
- Migrate all component code first (project broken during migration)
- Switch dependencies to v3
- Complete styling migration
### Incremental Migration
- Best for: Projects that must stay functional; large codebases migrating gradually
- Set up coexistence (pnpm aliases or component packages)
- Migrate components one-by-one
- Both v2 and v3 coexist during migration
**Always fetch the agent guide before starting:** `node scripts/get_migration_guide.mjs full` or `incremental`
---
## Core Principles
1. **Fetch first**: Use scripts to get migration guides before applying changes
2. **Compound components**: v3 uses `Card.Header`, `Card.Title`, `Button` with children—not flat props
3. **No Provider**: Remove `HeroUIProvider` when migrating
4. **onPress not onClick**: All interactive components use `onPress`
5. **Workflow**: Analyze → Migrate components → Switch deps → Styling migration
---
## Migration Workflow Summary
1. Create migration branch
2. Analyze project (HeroUI imports, component usage)
3. Fetch main guide: `node scripts/get_migration_guide.mjs full`
4. Migrate components in batches (fetch component guides per batch)
5. Switch dependencies to v3
6. Fetch styling guide: `node scripts/get_styling_migration_guide.mjs`
7. Apply styling updates
---
## Preview Mode
This skill targets the staging deployment of the `docs/migration` branch. Once docs are merged to main and live on heroui.com, set `HEROUI_MIGRATION_DOCS_BASE=https://heroui.com/docs/react/migration` or update the default in scripts.
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Get migration guides for HeroUI components (v2 to v3).
*
* Usage:
* node get_component_migration_guides.mjs button
* node get_component_migration_guides.mjs button card modal
*
* Output:
* MDX migration guide content for each component
*/
const DOCS_BASE =
process.env.HEROUI_MIGRATION_DOCS_BASE ||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
const APP_PARAM = "app=migration-skills";
/**
* Convert PascalCase or mixed case to kebab-case.
*/
function toKebabCase(name) {
return name
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
.toLowerCase()
.trim();
}
async function fetchDoc(filename) {
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
} catch (error) {
throw new Error(error.message);
}
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_component_migration_guides.mjs <Component1> [Component2] ...");
console.error("Example: node get_component_migration_guides.mjs button card modal");
console.error("\nUse list_migration_guides.mjs to see all available components.");
process.exit(1);
}
const components = args.map((c) => toKebabCase(c));
console.error(`# Fetching migration guides for: ${components.join(", ")}...`);
const results = [];
for (const component of components) {
const filename = `${component}.mdx`;
try {
const content = await fetchDoc(filename);
const title = component.charAt(0).toUpperCase() + component.slice(1).replace(/-/g, " ");
results.push({
component,
content: `# ${title} Migration Guide\n\n**Component:** ${component}\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
});
} catch (error) {
results.push({
component,
error: error.message,
});
}
}
const failed = results.filter((r) => r.error);
if (failed.length > 0) {
failed.forEach((r) => console.error(`# Error for ${r.component}: ${r.error}`));
}
if (results.length === 1) {
const r = results[0];
if (r.content) {
console.log(r.content);
} else {
console.log(JSON.stringify(r, null, 2));
process.exit(1);
}
} else {
const output = results
.map((r) => (r.content ? r.content : `# ${r.component} Migration Guide\n\nError: ${r.error}`))
.join("\n\n---\n\n");
console.log(output);
if (failed.length === results.length) {
process.exit(1);
}
}
}
main();
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Get the hooks migration guide for HeroUI v2 to v3.
*
* Usage:
* node get_hooks_migration_guide.mjs
*
* Output:
* MDX hooks migration guide content
*/
const DOCS_BASE =
process.env.HEROUI_MIGRATION_DOCS_BASE ||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
const APP_PARAM = "app=migration-skills";
async function fetchDoc(filename) {
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
} catch (error) {
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
}
}
async function main() {
const filename = "hooks.mdx";
console.error("# Fetching hooks migration guide...");
try {
const content = await fetchDoc(filename);
console.log(
`# HeroUI v2 to v3 Hooks Migration Guide\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
);
} catch (error) {
console.error(`# Error: ${error.message}`);
process.exit(1);
}
}
main();
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* Get the main migration workflow guide (full or incremental) for HeroUI v2 to v3.
*
* Usage:
* node get_migration_guide.mjs [full|incremental]
*
* Default: full
*
* Output:
* MDX migration guide content
*/
const DOCS_BASE =
process.env.HEROUI_MIGRATION_DOCS_BASE ||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
const APP_PARAM = "app=migration-skills";
async function fetchDoc(filename) {
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
} catch (error) {
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
}
}
async function main() {
const arg = (process.argv[2] || "full").toLowerCase();
const migrationType = arg === "incremental" ? "incremental" : "full";
const filename =
migrationType === "incremental" ? "agent-guide-incremental.mdx" : "agent-guide-full.mdx";
console.error(`# Fetching ${migrationType} migration guide...`);
try {
const content = await fetchDoc(filename);
const title =
migrationType === "incremental"
? "HeroUI v2 to v3 Agent Migration Guide - Incremental Migration"
: "HeroUI v2 to v3 Agent Migration Guide - Full Migration";
console.log(`# ${title}\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`);
} catch (error) {
console.error(`# Error: ${error.message}`);
process.exit(1);
}
}
main();
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Get the styling migration guide for HeroUI v2 to v3.
*
* Usage:
* node get_styling_migration_guide.mjs
*
* Output:
* MDX styling migration guide content
*/
const DOCS_BASE =
process.env.HEROUI_MIGRATION_DOCS_BASE ||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
const APP_PARAM = "app=migration-skills";
async function fetchDoc(filename) {
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
} catch (error) {
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
}
}
async function main() {
const filename = "styling.mdx";
console.error("# Fetching styling migration guide...");
try {
const content = await fetchDoc(filename);
console.log(
`# HeroUI v2 to v3 Styling Migration Guide\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
);
} catch (error) {
console.error(`# Error: ${error.message}`);
process.exit(1);
}
}
main();
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* List all available HeroUI v2 to v3 component migration guides.
*
* Usage:
* node list_migration_guides.mjs
*
* Output:
* List of component names that have migration guides
*
* Note: Keep in sync with migration-mcp list-migration-guides.ts when adding components.
*/
// Component migration guides available - must match migration-mcp list-migration-guides.ts
const AVAILABLE_COMPONENTS = [
"accordion",
"alert",
"autocomplete",
"avatar",
"breadcrumbs",
"button",
"button-group",
"card",
"checkbox",
"checkbox-group",
"chip",
"code",
"divider",
"dropdown",
"form",
"image",
"input",
"input-otp",
"kbd",
"link",
"listbox",
"modal",
"navbar",
"numberinput",
"popover",
"radio",
"radio-group",
"scroll-shadow",
"select",
"skeleton",
"slider",
"snippet",
"spacer",
"spinner",
"switch",
"tabs",
"toast",
"tooltip",
"user",
];
function main() {
const componentsList = AVAILABLE_COMPONENTS.map((name) => ` - ${name}`).join("\n");
console.log(
`# Available Component Migration Guides\n\nFound ${AVAILABLE_COMPONENTS.length} component migration guides:\n\n${componentsList}\n\nUse get_component_migration_guides.mjs with component names to fetch specific guides.\nExample: node get_component_migration_guides.mjs button card modal`,
);
}
main();
+199
View File
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an approval
from the project maintainers before using the Apache License.
Copyright 2025 NextUI Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+229
View File
@@ -0,0 +1,229 @@
---
name: heroui-native
description: "HeroUI Native component library for React Native (Tailwind v4 via Uniwind). Use when building mobile UIs with HeroUI Native — creating Buttons, Cards, TextFields, Dialogs; installing heroui-native; configuring dark/light themes; or fetching component docs. Keywords: HeroUI Native, heroui-native, React Native UI, Uniwind, mobile components."
metadata:
author: heroui
version: "2.0.1"
---
# HeroUI Native Development Guide
HeroUI Native is a component library built on **Uniwind (Tailwind CSS for React Native)** and **React Native**, providing accessible, customizable UI components for mobile applications.
---
## Installation
```bash
curl -fsSL https://heroui.com/install | bash -s heroui-native
```
---
## CRITICAL: Native Only - Do Not Use Web Patterns
**This guide is for HeroUI Native ONLY.** Do NOT apply HeroUI React (web) patterns — the package, styling engine, and color format all differ:
| Feature | React (Web) | Native (Mobile) |
| ------------ | -------------------- | ----------------------------------- |
| **Styling** | Tailwind CSS v4 | Uniwind (Tailwind for React Native) |
| **Colors** | oklch format | HSL format |
| **Package** | `@heroui/react` | `heroui-native` |
| **Platform** | Web browsers | iOS & Android |
```tsx
// CORRECT — Native pattern
import { Button } from "heroui-native";
<Button variant="primary" onPress={() => console.log("Pressed!")}>
Click me
</Button>;
```
**Always fetch Native docs before implementing.**
---
## Core Principles
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
- Composition over configuration (compound components)
- Theme variables with HSL color format
- React Native StyleSheet patterns with Uniwind utilities
---
## Accessing Documentation & Component Information
**For component details, examples, props, and implementation patterns, always fetch documentation:**
### Using Scripts
```bash
# List all available components
node scripts/list_components.mjs
# Get component documentation (MDX)
node scripts/get_component_docs.mjs Button
node scripts/get_component_docs.mjs Button Card TextField
# Get theme variables
node scripts/get_theme.mjs
# Get non-component docs (guides, releases)
node scripts/get_docs.mjs /docs/native/getting-started/theming
```
### Direct MDX URLs
Component docs: `https://heroui.com/docs/native/components/{component-name}.mdx`
Examples:
- Button: `https://heroui.com/docs/native/components/button.mdx`
- Dialog: `https://heroui.com/docs/native/components/dialog.mdx`
- TextField: `https://heroui.com/docs/native/components/text-field.mdx`
Getting started guides: `https://heroui.com/docs/native/getting-started/{topic}.mdx`
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
---
## Installation Essentials
### Quick Install
```bash
npm i heroui-native react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
```
### Framework Setup (Expo - Recommended)
1. **Install dependencies:**
```bash
npx create-expo-app MyApp
cd MyApp
npm i heroui-native uniwind tailwindcss
npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
```
2. **Create `global.css`:**
```css
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
@source "./node_modules/heroui-native/lib";
```
3. **Wrap app with providers:**
```tsx
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { HeroUINativeProvider } from "heroui-native";
import "./global.css";
export default function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<HeroUINativeProvider>
<App />
</HeroUINativeProvider>
</GestureHandlerRootView>
);
}
```
### Critical Setup Requirements
1. **Uniwind is Required** - HeroUI Native uses Uniwind (Tailwind CSS for React Native)
2. **HeroUINativeProvider Required** - Wrap your app with `HeroUINativeProvider`
3. **GestureHandlerRootView Required** - Wrap with `GestureHandlerRootView` from react-native-gesture-handler
4. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Body`)
5. **Use onPress, not onClick** - React Native uses `onPress` event handlers
6. **Platform-Specific Code** - Use `Platform.OS` for iOS/Android differences
---
## Component Patterns
HeroUI Native uses **compound component patterns**. Each component has subcomponents accessed via dot notation.
**Example - Card:**
```tsx
<Card>
<Card.Header>{/* Icons, badges */}</Card.Header>
<Card.Body>
<Card.Title>Title</Card.Title>
<Card.Description>Description</Card.Description>
</Card.Body>
<Card.Footer>{/* Actions */}</Card.Footer>
</Card>
```
**Key Points:**
- Always use compound structure - don't flatten to props
- Subcomponents are accessed via dot notation (e.g., `Card.Header`)
- Native Card uses `Card.Body` (not `Card.Content`); Title and Description go inside Body
- **Fetch component docs for complete anatomy and examples**
---
## Semantic Variants
HeroUI uses semantic naming to communicate functional intent:
| Variant | Purpose | Usage |
| ------------- | --------------------------------- | -------------- |
| `primary` | Main action to move forward | 1 per context |
| `secondary` | Alternative actions | Multiple |
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
| `danger` | Destructive actions | When needed |
| `danger-soft` | Soft destructive actions | Less prominent |
| `ghost` | Low-emphasis actions | Minimal weight |
| `outline` | Secondary actions | Bordered style |
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
---
## Theming
HeroUI Native uses CSS variables via Tailwind/Uniwind for theming. Theme colors are defined in `global.css`:
```css
@theme {
--color-accent: hsl(260, 100%, 70%);
--color-accent-foreground: hsl(0, 0%, 100%);
}
```
**Get current theme variables:**
```bash
node scripts/get_theme.mjs
```
**Access theme colors programmatically:**
```tsx
import { useThemeColor } from "heroui-native";
const accentColor = useThemeColor("accent");
```
**Theme switching (Light/Dark Mode):**
```tsx
import { Uniwind, useUniwind } from "uniwind";
const { theme } = useUniwind();
Uniwind.setTheme(theme === "light" ? "dark" : "light");
```
For detailed theming, fetch: `https://heroui.com/docs/native/getting-started/theming.mdx`
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Get complete component documentation (MDX) for HeroUI Native components.
*
* Usage:
* node get_component_docs.mjs Button
* node get_component_docs.mjs Button Card TextField
*
* Output:
* MDX documentation including imports, usage, variants, props, examples
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=native-skills";
/**
* Convert PascalCase to kebab-case.
*/
function toKebabCase(name) {
return name
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
}
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint, method = "GET", body = null) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const options = {
headers: {
"Content-Type": "application/json",
"User-Agent": "HeroUI-Native-Skill/1.0",
},
method,
signal: AbortSignal.timeout(30000),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
return null;
}
return await response.json();
} catch {
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(component) {
const kebabName = toKebabCase(component);
const url = `${FALLBACK_BASE}/docs/native/components/${kebabName}.mdx`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {component, error: `Failed to fetch docs for ${component}`};
}
const content = await response.text();
return {
component,
content,
contentType: "mdx",
source: "fallback",
url,
};
} catch {
return {component, error: `Failed to fetch docs for ${component}`};
}
}
/**
* Main function to get component documentation.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
console.error("Example: node get_component_docs.mjs Button Card");
process.exit(1);
}
const components = args;
// Try API first - use POST /v1/components/docs for batch requests
console.error(`# Fetching Native docs for: ${components.join(", ")}...`);
const data = await fetchApi("/v1/components/docs", "POST", {components});
if (data && data.results) {
// Output results
if (data.results.length === 1) {
// Single component - output content directly for easier reading
const result = data.results[0];
if (result.content) {
console.log(result.content);
} else if (result.error) {
console.error(`# Error for ${result.component}: ${result.error}`);
console.log(JSON.stringify(result, null, 2));
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(data, null, 2));
}
return;
}
// Fallback to individual component fetches
console.error("# API failed, using fallback...");
const results = [];
for (const component of components) {
const result = await fetchFallback(component);
results.push(result);
}
// Output results
if (results.length === 1) {
// Single component - output content directly for easier reading
const result = results[0];
if (result.content) {
console.log(result.content);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(results, null, 2));
}
}
main();
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Get non-component HeroUI Native documentation (guides, theming, releases).
*
* Usage:
* node get_docs.mjs /docs/native/getting-started/theming
* node get_docs.mjs /docs/native/releases/beta-12
*
* Output:
* MDX documentation content
*
* Note: For component docs, use get_component_docs.mjs instead.
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=native-skills";
/**
* Fetch documentation from HeroUI Native API.
* Uses v1 endpoint pattern: /v1/docs/:path
*/
async function fetchApi(path) {
// The v1 API expects path without /docs/ prefix
// Input: /docs/native/getting-started/theming
// API expects: native/getting-started/theming (route is /v1/docs/:path(*))
let apiPath = path.startsWith("/docs/")
? path.slice(6) // Remove /docs/ prefix
: path.startsWith("/")
? path.slice(1) // Remove leading /
: path;
const separator = "?";
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(path) {
// Ensure path starts with /docs and ends with .mdx
let cleanPath = path.replace(/^\//, "");
if (!cleanPath.endsWith(".mdx")) {
cleanPath = `${cleanPath}.mdx`;
}
const url = `${FALLBACK_BASE}/${cleanPath}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {error: `HTTP ${response.status}: ${response.statusText}`, path};
}
const content = await response.text();
return {
content,
contentType: "mdx",
path,
source: "fallback",
url,
};
} catch (error) {
return {error: `Fetch Error: ${error.message}`, path};
}
}
/**
* Main function to get documentation for specified path.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_docs.mjs <path>");
console.error("Example: node get_docs.mjs /docs/native/getting-started/theming");
console.error();
console.error("Available paths include:");
console.error(" /docs/native/getting-started/theming");
console.error(" /docs/native/getting-started/colors");
console.error(" /docs/native/getting-started/styling");
console.error(" /docs/native/releases/beta-12");
console.error();
console.error("Note: For component docs, use get_component_docs.mjs instead.");
process.exit(1);
}
const path = args[0];
// Check if user is trying to get component docs
if (path.includes("/components/")) {
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
const componentName = path.split("/").pop().replace(".mdx", "");
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
}
// Validate Native path
if (!path.startsWith("/docs/native/")) {
console.error("# Warning: Native documentation paths should start with /docs/native/");
console.error(`# Provided path: ${path}`);
}
console.error(`# Fetching Native documentation for ${path}...`);
// Try API first
const data = await fetchApi(path);
if (data && data.content) {
data.source = "api";
console.log(data.content);
return;
}
// Fallback to direct fetch
console.error("# API failed, using fallback...");
const fallbackData = await fetchFallback(path);
if (fallbackData.content) {
console.log(fallbackData.content);
} else {
console.log(JSON.stringify(fallbackData, null, 2));
process.exit(1);
}
}
main();
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* Get theme variables and design tokens for HeroUI Native.
*
* Usage:
* node get_theme.mjs
*
* Output:
* Theme variables organized by light/dark with HSL color format
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const APP_PARAM = "app=native-skills";
// Fallback theme reference when API is unavailable
const FALLBACK_THEME = {
borderRadius: {
full: 9999,
lg: 12,
md: 8,
sm: 6,
},
dark: {
colors: [
{
category: "base",
name: "--color-background",
value: "hsl(0, 0%, 14.5%)",
},
{
category: "semantic",
name: "--color-foreground",
value: "hsl(0, 0%, 98.4%)",
},
{
category: "semantic",
name: "--color-accent",
value: "hsl(264.1, 100%, 55.1%)",
},
{
category: "status",
name: "--color-danger",
value: "hsl(25.3, 100%, 63.7%)",
},
{
category: "status",
name: "--color-success",
value: "hsl(163.2, 100%, 76.5%)",
},
{
category: "status",
name: "--color-warning",
value: "hsl(86.0, 100%, 79.5%)",
},
],
},
latestVersion: "beta",
light: {
colors: [
{
category: "base",
name: "--color-background",
value: "hsl(0, 0%, 100%)",
},
{
category: "semantic",
name: "--color-foreground",
value: "hsl(285.89, 5.9%, 21.03%)",
},
{
category: "semantic",
name: "--color-accent",
value: "hsl(253.83, 100%, 62.04%)",
},
{
category: "status",
name: "--color-danger",
value: "hsl(25.74, 100%, 65.32%)",
},
{
category: "status",
name: "--color-success",
value: "hsl(150.81, 100%, 73.29%)",
},
{
category: "status",
name: "--color-warning",
value: "hsl(72.33, 100%, 78.19%)",
},
],
},
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
opacity: {
disabled: 0.4,
hover: 0.8,
pressed: 0.6,
},
source: "fallback",
theme: "default",
};
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Format colors grouped by category.
*/
function formatColors(colors) {
const grouped = {};
for (const color of colors) {
const category = color.category || "semantic";
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(color);
}
const lines = [];
for (const [category, tokens] of Object.entries(grouped)) {
lines.push(` /* ${category.charAt(0).toUpperCase() + category.slice(1)} Colors */`);
for (const token of tokens) {
const name = token.name || "";
const value = token.value || "";
lines.push(` ${name}: ${value};`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Main function to get theme variables.
*/
async function main() {
console.error("# Fetching Native theme variables...");
const rawData = await fetchApi("/v1/themes/variables?theme=default");
let data;
let version;
if (!rawData) {
console.error("# API failed, using fallback theme reference...");
data = FALLBACK_THEME;
version = FALLBACK_THEME.latestVersion || "unknown";
} else {
// Handle API response format
data = rawData;
version = rawData.latestVersion || "unknown";
}
// Output as formatted structure for readability
console.log("/* HeroUI Native Theme Variables */");
console.log(`/* Theme: ${data.theme || "default"} */`);
console.log(`/* Version: ${version} */`);
console.log();
// Light mode colors
if (data.light && data.light.colors) {
console.log("/* Light Mode Colors */");
console.log(formatColors(data.light.colors));
}
// Dark mode colors
if (data.dark && data.dark.colors) {
console.log("/* Dark Mode Colors */");
console.log(formatColors(data.dark.colors));
}
// Border radius
if (data.borderRadius) {
console.log("/* Border Radius */");
for (const [key, value] of Object.entries(data.borderRadius)) {
console.log(` --radius-${key}: ${value};`);
}
console.log();
}
// Opacity
if (data.opacity) {
console.log("/* Opacity */");
for (const [key, value] of Object.entries(data.opacity)) {
console.log(` --opacity-${key}: ${value};`);
}
console.log();
}
// Also output raw JSON to stderr for programmatic use
console.error("\n# Raw JSON output:");
console.error(JSON.stringify(rawData || data, null, 2));
}
main();
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* List all available HeroUI Native components.
*
* Usage:
* node list_components.mjs
*
* Output:
* JSON with components array, latestVersion, and count
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const APP_PARAM = "app=native-skills";
const LLMS_TXT_URL = "https://heroui.com/native/llms.txt";
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`API Error: ${error.message}`);
return null;
}
}
/**
* Fetch component list from llms.txt fallback URL.
*/
async function fetchFallback() {
try {
const response = await fetch(LLMS_TXT_URL, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return null;
}
const content = await response.text();
// Parse markdown to extract component names from pattern: - [ComponentName](url)
// Look for links under the Components section (### Components)
const components = [];
let inComponentsSection = false;
for (const line of content.split("\n")) {
// Check if we're entering the Components section (uses ### header)
if (line.trim() === "### Components") {
inComponentsSection = true;
continue;
}
// Check if we're leaving the Components section (another ### header)
if (inComponentsSection && line.trim().startsWith("### ")) {
break;
}
// Extract component name from markdown link pattern
// Match: - [ComponentName](https://www.heroui.com/docs/native/components/component-name)
// Skip "All Components" which links to /components without a specific component
if (inComponentsSection) {
const match = line.match(
/^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/native\/components\/[a-z]/,
);
if (match) {
components.push(match[1]);
}
}
}
if (components.length > 0) {
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
return {
components: components.sort(),
count: components.length,
latestVersion: "unknown",
};
}
return null;
} catch (error) {
console.error(`Fallback Error: ${error.message}`);
return null;
}
}
/**
* Main function to list all available HeroUI Native components.
*/
async function main() {
let data = await fetchApi("/v1/components");
// Check if API returned valid data with components
if (!data || !data.components || data.components.length === 0) {
console.error("# API returned no components, trying fallback...");
data = await fetchFallback();
}
if (!data || !data.components || data.components.length === 0) {
console.error("Error: Failed to fetch component list from API and fallback");
process.exit(1);
}
// Output formatted JSON
console.log(JSON.stringify(data, null, 2));
// Print summary to stderr for human readability
console.error(
`\n# Found ${data.components.length} Native components (${data.latestVersion || "unknown"})`,
);
}
main();
+199
View File
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an approval
from the project maintainers before using the Apache License.
Copyright 2025 NextUI Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+234
View File
@@ -0,0 +1,234 @@
---
name: heroui-react
description: "HeroUI v3 React component library (Tailwind CSS v4 + React Aria). Use when building UIs with HeroUI — creating Buttons, Modals, Forms, Cards; installing @heroui/react; configuring dark/light themes with oklch variables; or fetching component docs. Keywords: HeroUI, Hero UI, heroui, @heroui/react, @heroui/styles."
metadata:
author: heroui
version: "3.0.1"
---
# HeroUI v3 React Development Guide
HeroUI v3 is a component library built on **Tailwind CSS v4** and **React Aria Components**, providing accessible, customizable UI components for React applications.
---
## Installation
```bash
curl -fsSL https://heroui.com/install | bash -s heroui-react
```
---
## CRITICAL: v3 Only - Ignore v2 Knowledge
**This guide is for HeroUI v3 ONLY.** Do NOT apply v2 patterns — the provider, styling, and component API all changed:
| Feature | v2 (DO NOT USE) | v3 (USE THIS) |
| ------------- | --------------------------------- | ------------------------------------------- |
| Provider | `<HeroUIProvider>` required | **No Provider needed** |
| Animations | `framer-motion` package | CSS-based, no extra deps |
| Component API | Flat props: `<Card title="x">` | Compound: `<Card><Card.Header>` |
| Styling | Tailwind v3 + `@heroui/theme` | Tailwind v4 + `@heroui/styles` |
| Packages | `@heroui/system`, `@heroui/theme` | `@heroui/react`, `@heroui/styles` |
```tsx
// DO NOT DO THIS - v2 pattern
import { HeroUIProvider } from "@heroui/react";
import { motion } from "framer-motion";
<HeroUIProvider>
<Card title="Product" description="A great product" />
</HeroUIProvider>;
```
### CORRECT (v3 patterns)
```tsx
// DO THIS - v3 pattern (no provider, compound components)
import { Card } from "@heroui/react";
<Card>
<Card.Header>
<Card.Title>Product</Card.Title>
<Card.Description>A great product</Card.Description>
</Card.Header>
</Card>;
```
**Always fetch v3 docs before implementing.**
---
## Core Principles
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
- Composition over configuration (compound components)
- CSS variable-based theming with `oklch` color space
- BEM naming convention for predictable styling
---
## Accessing Documentation & Component Information
**For component details, examples, props, and implementation patterns, always fetch documentation:**
### Using Scripts
```bash
# List all available components
node scripts/list_components.mjs
# Get component documentation (MDX)
node scripts/get_component_docs.mjs Button
node scripts/get_component_docs.mjs Button Card TextField
# Get component source code
node scripts/get_source.mjs Button
# Get component CSS styles (BEM classes)
node scripts/get_styles.mjs Button
# Get theme variables
node scripts/get_theme.mjs
# Get non-component docs (guides, releases)
node scripts/get_docs.mjs /docs/react/getting-started/theming
```
### Direct MDX URLs
Component docs: `https://heroui.com/docs/react/components/{component-name}.mdx`
Examples:
- Button: `https://heroui.com/docs/react/components/button.mdx`
- Modal: `https://heroui.com/docs/react/components/modal.mdx`
- Form: `https://heroui.com/docs/react/components/form.mdx`
Getting started guides: `https://heroui.com/docs/react/getting-started/{topic}.mdx`
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
---
## Installation Essentials
### Quick Install
```bash
npm i @heroui/styles @heroui/react tailwind-variants
```
### Framework Setup (Next.js App Router - Recommended)
1. **Install dependencies:**
```bash
npm i @heroui/styles @heroui/react tailwind-variants tailwindcss @tailwindcss/postcss postcss
```
2. **Create/update `app/globals.css`:**
```css
/* Tailwind CSS v4 - Must be first */
@import "tailwindcss";
/* HeroUI v3 styles - Must be after Tailwind */
@import "@heroui/styles";
```
3. **Import in `app/layout.tsx`:**
```tsx
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
{/* No Provider needed in HeroUI v3! */}
{children}
</body>
</html>
);
}
```
4. **Configure PostCSS (`postcss.config.mjs`):**
```js
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
```
### Critical Setup Requirements
1. **Tailwind CSS v4 is MANDATORY** - HeroUI v3 will NOT work with Tailwind CSS v3
2. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Content`)
3. **Use onPress, not onClick** - For better accessibility, use `onPress` event handlers
4. **Import Order Matters** - Always import Tailwind CSS before HeroUI styles
---
## Component Patterns
All components use the **compound pattern** shown above (dot-notation subcomponents like `Card.Header`, `Card.Content`). Don't flatten to props — always compose with subcomponents. Fetch component docs for complete anatomy and examples.
---
## Semantic Variants
HeroUI uses semantic naming to communicate functional intent:
| Variant | Purpose | Usage |
| ----------- | --------------------------------- | -------------- |
| `primary` | Main action to move forward | 1 per context |
| `secondary` | Alternative actions | Multiple |
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
| `danger` | Destructive actions | When needed |
| `ghost` | Low-emphasis actions | Minimal weight |
| `outline` | Secondary actions | Bordered style |
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
---
## Theming
HeroUI v3 uses CSS variables with `oklch` color space:
```css
:root {
--accent: oklch(0.6204 0.195 253.83);
--accent-foreground: var(--snow);
--background: oklch(0.9702 0 0);
--foreground: var(--eclipse);
}
```
**Get current theme variables:**
```bash
node scripts/get_theme.mjs
```
**Color naming:**
- Without suffix = background (e.g., `--accent`)
- With `-foreground` = text color (e.g., `--accent-foreground`)
**Theme switching:**
```html
<html class="dark" data-theme="dark"></html>
```
For detailed theming, fetch: `https://heroui.com/docs/react/getting-started/theming.mdx`
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Get complete component documentation (MDX) for HeroUI v3 components.
*
* Usage:
* node get_component_docs.mjs Button
* node get_component_docs.mjs Button Card TextField
*
* Output:
* MDX documentation including imports, usage, variants, props, examples
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=react-skills";
/**
* Convert PascalCase to kebab-case.
*/
function toKebabCase(name) {
return name
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
}
/**
* Fetch data from HeroUI API with app parameter for analytics.
*/
async function fetchApi(endpoint, method = "GET", body = null) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const options = {
headers: {
"Content-Type": "application/json",
"User-Agent": "HeroUI-Skill/1.0",
},
method,
signal: AbortSignal.timeout(30000),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
return null;
}
return await response.json();
} catch {
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(component) {
const kebabName = toKebabCase(component);
const url = `${FALLBACK_BASE}/docs/react/components/${kebabName}.mdx`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {component, error: `Failed to fetch docs for ${component}`};
}
const content = await response.text();
return {
component,
content,
contentType: "mdx",
source: "fallback",
url,
};
} catch {
return {component, error: `Failed to fetch docs for ${component}`};
}
}
/**
* Main function to get component documentation.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
console.error("Example: node get_component_docs.mjs Button Card");
process.exit(1);
}
const components = args;
// Try API first - use POST /v1/components/docs for batch requests
console.error(`# Fetching docs for: ${components.join(", ")}...`);
const data = await fetchApi("/v1/components/docs", "POST", {components});
if (data && data.results) {
// Output results
if (data.results.length === 1) {
// Single component - output content directly for easier reading
const result = data.results[0];
if (result.content) {
console.log(result.content);
} else if (result.error) {
console.error(`# Error for ${result.component}: ${result.error}`);
console.log(JSON.stringify(result, null, 2));
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(data, null, 2));
}
return;
}
// Fallback to individual component fetches
console.error("# API failed, using fallback...");
const results = [];
for (const component of components) {
const result = await fetchFallback(component);
results.push(result);
}
// Output results
if (results.length === 1) {
// Single component - output content directly for easier reading
const result = results[0];
if (result.content) {
console.log(result.content);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(results, null, 2));
}
}
main();
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* Get non-component HeroUI documentation (guides, theming, releases).
*
* Usage:
* node get_docs.mjs /docs/react/getting-started/theming
* node get_docs.mjs /docs/react/releases/v3-0-0-beta-3
*
* Output:
* MDX documentation content
*
* Note: For component docs, use get_component_docs.mjs instead.
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=react-skills";
/**
* Fetch documentation from HeroUI API.
* Uses v1 endpoint pattern: /v1/docs/:path
*/
async function fetchApi(path) {
// The v1 API expects path without /docs/ prefix
// Input: /docs/react/getting-started/theming
// API expects: react/getting-started/theming (route is /v1/docs/:path(*))
let apiPath = path.startsWith("/docs/")
? path.slice(6) // Remove /docs/ prefix
: path.startsWith("/")
? path.slice(1) // Remove leading /
: path;
const separator = "?";
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(path) {
// Ensure path starts with /docs and ends with .mdx
let cleanPath = path.replace(/^\//, "");
if (!cleanPath.endsWith(".mdx")) {
cleanPath = `${cleanPath}.mdx`;
}
const url = `${FALLBACK_BASE}/${cleanPath}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {error: `HTTP ${response.status}: ${response.statusText}`, path};
}
const content = await response.text();
return {
content,
contentType: "mdx",
path,
source: "fallback",
url,
};
} catch (error) {
return {error: `Fetch Error: ${error.message}`, path};
}
}
/**
* Main function to get documentation for specified path.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_docs.mjs <path>");
console.error("Example: node get_docs.mjs /docs/react/getting-started/theming");
console.error();
console.error("Available paths include:");
console.error(" /docs/react/getting-started/theming");
console.error(" /docs/react/getting-started/colors");
console.error(" /docs/react/getting-started/animations");
console.error(" /docs/react/releases/v3-0-0-beta-3");
console.error();
console.error("Note: For component docs, use get_component_docs.mjs instead.");
process.exit(1);
}
const path = args[0];
// Check if user is trying to get component docs
if (path.includes("/components/")) {
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
const componentName = path.split("/").pop().replace(".mdx", "");
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
}
console.error(`# Fetching documentation for ${path}...`);
// Try API first
const data = await fetchApi(path);
if (data && data.content) {
data.source = "api";
console.log(data.content);
return;
}
// Fallback to direct fetch
console.error("# API failed, using fallback...");
const fallbackData = await fetchFallback(path);
if (fallbackData.content) {
console.log(fallbackData.content);
} else {
console.log(JSON.stringify(fallbackData, null, 2));
process.exit(1);
}
}
main();
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Get React/TypeScript source code implementation for HeroUI v3 components.
*
* Usage:
* node get_source.mjs Button
* node get_source.mjs Button Accordion Card
*
* Output:
* Full TSX source code with GitHub URL for each component
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/heroui-inc/heroui/refs/heads/v3";
const APP_PARAM = "app=react-skills";
/**
* Fetch data from HeroUI API with app parameter for analytics.
*/
async function fetchApi(endpoint, method = "GET", body = null) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const options = {
headers: {
"Content-Type": "application/json",
"User-Agent": "HeroUI-Skill/1.0",
},
method,
signal: AbortSignal.timeout(30000),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Fetch source code directly from GitHub as fallback.
*/
async function fetchGithubFallback(component) {
// Try common patterns for component paths
const patterns = [
`packages/react/src/components/${component.toLowerCase()}/${component.toLowerCase()}.tsx`,
`packages/react/src/components/${component.toLowerCase()}/index.tsx`,
];
for (const path of patterns) {
const url = `${GITHUB_RAW_BASE}/${path}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (response.ok) {
const content = await response.text();
return {
component,
filePath: path,
githubUrl: `https://github.com/heroui-inc/heroui/blob/v3/${path}`,
source: "fallback",
sourceCode: content,
};
}
} catch {
continue;
}
}
return {component, error: `Failed to fetch source for ${component}`};
}
/**
* Main function to get source code for specified components.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_source.mjs <Component1> [Component2] ...");
console.error("Example: node get_source.mjs Button Accordion");
process.exit(1);
}
const components = args;
// Try API first
console.error(`# Fetching source code for: ${components.join(", ")}...`);
const data = await fetchApi("/v1/components/source", "POST", {components});
if (data && data.results) {
for (const result of data.results) {
result.source = "api";
}
// Output results
if (data.results.length === 1) {
const result = data.results[0];
if (result.sourceCode) {
console.log(`// File: ${result.filePath || "unknown"}`);
console.log(`// GitHub: ${result.githubUrl || "unknown"}`);
console.log();
console.log(result.sourceCode);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
console.log(JSON.stringify(data, null, 2));
}
return;
}
// Fallback to GitHub direct fetch
console.error("# API failed, using GitHub fallback...");
const results = [];
for (const component of components) {
const result = await fetchGithubFallback(component);
results.push(result);
}
if (results.length === 1) {
const result = results[0];
if (result.sourceCode) {
console.log(`// File: ${result.filePath || "unknown"}`);
console.log(`// GitHub: ${result.githubUrl || "unknown"}`);
console.log();
console.log(result.sourceCode);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
console.log(JSON.stringify({results}, null, 2));
}
}
main();
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Get CSS styles (BEM classes) for HeroUI v3 components.
*
* Usage:
* node get_styles.mjs Button
* node get_styles.mjs Button Card Chip
*
* Output:
* CSS file content with BEM classes and GitHub URL for each component
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/heroui-inc/heroui/refs/heads/v3";
const APP_PARAM = "app=react-skills";
/**
* Fetch data from HeroUI API with app parameter for analytics.
*/
async function fetchApi(endpoint, method = "GET", body = null) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const options = {
headers: {
"Content-Type": "application/json",
"User-Agent": "HeroUI-Skill/1.0",
},
method,
signal: AbortSignal.timeout(30000),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Fetch CSS styles directly from GitHub as fallback.
*/
async function fetchGithubFallback(component) {
// Try common patterns for style paths
const patterns = [
`packages/styles/src/components/${component.toLowerCase()}.css`,
`packages/styles/components/${component.toLowerCase()}.css`,
];
for (const path of patterns) {
const url = `${GITHUB_RAW_BASE}/${path}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (response.ok) {
const content = await response.text();
return {
component,
filePath: path,
githubUrl: `https://github.com/heroui-inc/heroui/blob/v3/${path}`,
source: "fallback",
stylesCode: content,
};
}
} catch {
continue;
}
}
return {component, error: `Failed to fetch styles for ${component}`};
}
/**
* Main function to get CSS styles for specified components.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_styles.mjs <Component1> [Component2] ...");
console.error("Example: node get_styles.mjs Button Card");
process.exit(1);
}
const components = args;
// Try API first
console.error(`# Fetching styles for: ${components.join(", ")}...`);
const data = await fetchApi("/v1/components/styles", "POST", {components});
if (data && data.results) {
for (const result of data.results) {
result.source = "api";
}
// Output results
if (data.results.length === 1) {
const result = data.results[0];
if (result.stylesCode) {
console.log(`/* File: ${result.filePath || "unknown"} */`);
console.log(`/* GitHub: ${result.githubUrl || "unknown"} */`);
console.log();
console.log(result.stylesCode);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
console.log(JSON.stringify(data, null, 2));
}
return;
}
// Fallback to GitHub direct fetch
console.error("# API failed, using GitHub fallback...");
const results = [];
for (const component of components) {
const result = await fetchGithubFallback(component);
results.push(result);
}
if (results.length === 1) {
const result = results[0];
if (result.stylesCode) {
console.log(`/* File: ${result.filePath || "unknown"} */`);
console.log(`/* GitHub: ${result.githubUrl || "unknown"} */`);
console.log();
console.log(result.stylesCode);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
console.log(JSON.stringify({results}, null, 2));
}
}
main();
@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* Get theme variables and design tokens for HeroUI v3.
*
* Usage:
* node get_theme.mjs
*
* Output:
* Theme variables organized by common/light/dark with oklch color format
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const APP_PARAM = "app=react-skills";
// Fallback theme reference when API is unavailable
const FALLBACK_THEME = {
common: {
base: [
{name: "--font-sans", value: "ui-sans-serif, system-ui, sans-serif"},
{name: "--font-mono", value: "ui-monospace, monospace"},
{name: "--radius-sm", value: "0.375rem"},
{name: "--radius-md", value: "0.5rem"},
{name: "--radius-lg", value: "0.75rem"},
{name: "--radius-full", value: "9999px"},
],
calculated: [{name: "--spacing-unit", value: "0.25rem"}],
},
dark: {
semantic: [
{name: "--color-background", value: "oklch(14.5% 0 0)"},
{name: "--color-foreground", value: "oklch(98.4% 0 0)"},
{name: "--color-accent", value: "oklch(55.1% 0.228 264.1)"},
{name: "--color-danger", value: "oklch(63.7% 0.237 25.3)"},
{name: "--color-success", value: "oklch(76.5% 0.177 163.2)"},
{name: "--color-warning", value: "oklch(79.5% 0.184 86.0)"},
],
},
latestVersion: "3.0.0-beta",
light: {
semantic: [
{name: "--color-background", value: "oklch(100% 0 0)"},
{name: "--color-foreground", value: "oklch(14.5% 0 0)"},
{name: "--color-accent", value: "oklch(55.1% 0.228 264.1)"},
{name: "--color-danger", value: "oklch(63.7% 0.237 25.3)"},
{name: "--color-success", value: "oklch(76.5% 0.177 163.2)"},
{name: "--color-warning", value: "oklch(79.5% 0.184 86.0)"},
],
},
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
source: "fallback",
theme: "default",
};
/**
* Fetch data from HeroUI API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Format theme variables for display.
*/
function formatVariables(variables) {
const lines = [];
for (const variable of variables) {
const name = variable.name || "";
const value = variable.value || "";
const desc = variable.description || "";
if (desc) {
lines.push(` ${name}: ${value}; /* ${desc} */`);
} else {
lines.push(` ${name}: ${value};`);
}
}
return lines.join("\n");
}
/**
* Main function to get theme variables.
*/
async function main() {
console.error("# Fetching theme variables...");
const rawData = await fetchApi("/v1/themes/variables?theme=default");
let data;
let version;
if (!rawData) {
console.error("# API failed, using fallback theme reference...");
data = FALLBACK_THEME;
version = FALLBACK_THEME.latestVersion || "unknown";
} else {
// Handle API response format: { themes: [...], latestVersion: "..." }
if (rawData.themes && rawData.themes.length > 0) {
data = rawData.themes[0]; // Get first theme (default)
version = rawData.latestVersion || rawData.version || "unknown";
} else {
// Direct format
data = rawData;
version = rawData.latestVersion || "unknown";
}
}
// Output as formatted CSS-like structure for readability
console.log("/* HeroUI v3 Theme Variables */");
console.log(`/* Theme: ${data.theme || "default"} */`);
console.log(`/* Version: ${version} */`);
console.log();
// Common variables
if (data.common) {
console.log(":root {");
console.log(" /* Base Variables */");
if (data.common.base) {
console.log(formatVariables(data.common.base));
}
console.log();
console.log(" /* Calculated Variables */");
if (data.common.calculated) {
console.log(formatVariables(data.common.calculated));
}
console.log("}");
console.log();
}
// Light mode
if (data.light) {
console.log(":root, [data-theme='light'] {");
console.log(" /* Light Mode Semantic Variables */");
if (data.light.semantic) {
console.log(formatVariables(data.light.semantic));
}
console.log("}");
console.log();
}
// Dark mode
if (data.dark) {
console.log("[data-theme='dark'] {");
console.log(" /* Dark Mode Semantic Variables */");
if (data.dark.semantic) {
console.log(formatVariables(data.dark.semantic));
}
console.log("}");
}
// Also output raw JSON to stderr for programmatic use
console.error("\n# Raw JSON output:");
console.error(JSON.stringify(rawData || data, null, 2));
}
main();
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* List all available HeroUI v3 components.
*
* Usage:
* node list_components.mjs
*
* Output:
* JSON with components array, latestVersion, and count
*/
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
const APP_PARAM = "app=react-skills";
const LLMS_TXT_URL = "https://heroui.com/react/llms.txt";
/**
* Fetch data from HeroUI API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`API Error: ${error.message}`);
return null;
}
}
/**
* Fetch component list from llms.txt fallback URL.
*/
async function fetchFallback() {
try {
const response = await fetch(LLMS_TXT_URL, {
headers: {"User-Agent": "HeroUI-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return null;
}
const content = await response.text();
// Parse markdown to extract component names from pattern: - [ComponentName](url)
// Look for links under the Components section (### Components)
const components = [];
let inComponentsSection = false;
for (const line of content.split("\n")) {
// Check if we're entering the Components section (uses ### header)
if (line.trim() === "### Components") {
inComponentsSection = true;
continue;
}
// Check if we're leaving the Components section (another ### header)
if (inComponentsSection && line.trim().startsWith("### ")) {
break;
}
// Extract component name from markdown link pattern
// Match: - [ComponentName](https://www.heroui.com/docs/react/components/component-name)
// Skip "All Components" which links to /components without a specific component
if (inComponentsSection) {
const match = line.match(
/^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/react\/components\/[a-z]/,
);
if (match) {
components.push(match[1]);
}
}
}
if (components.length > 0) {
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
return {
components: components.sort(),
count: components.length,
latestVersion: "unknown",
};
}
return null;
} catch (error) {
console.error(`Fallback Error: ${error.message}`);
return null;
}
}
/**
* Main function to list all available HeroUI v3 components.
*/
async function main() {
let data = await fetchApi("/v1/components");
// Check if API returned valid data with components
if (!data || !data.components || data.components.length === 0) {
console.error("# API returned no components, trying fallback...");
data = await fetchFallback();
}
if (!data || !data.components || data.components.length === 0) {
console.error("Error: Failed to fetch component list from API and fallback");
process.exit(1);
}
// Output formatted JSON
console.log(JSON.stringify(data, null, 2));
// Print summary to stderr for human readability
console.error(
`\n# Found ${data.components.length} components (${data.latestVersion || "unknown"})`,
);
}
main();
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/heroui-migration
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/heroui-native
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/heroui-react
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug report
about: Report a problem with temetro
labels: bug
---
**Describe the bug**
A clear description of what the bug is.
**To reproduce**
Steps to reproduce the behavior:
1.
2.
3.
**Expected behavior**
What you expected to happen.
**Actual behavior**
What actually happened.
**Environment**
- OS:
- Browser (if frontend):
- Node version:
- Are you running via Docker or local dev?
**Screenshots / logs**
If applicable, add screenshots or relevant log output.
**Additional context**
Any other context about the problem.
+22
View File
@@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea or improvement for temetro
labels: enhancement
---
**Is your feature request related to a problem?**
A clear description of the problem. e.g. "I'm frustrated when..."
**Describe the solution you'd like**
A clear description of what you want to happen.
**Which part of the project does this affect?**
- [ ] Frontend (Next.js chat UI)
- [ ] Backend (API / auth / database)
- [ ] Both
**Alternatives considered**
Any alternative solutions or features you've considered.
**Additional context**
Any other context, mockups, or screenshots.
+21
View File
@@ -0,0 +1,21 @@
## Summary
<!-- What does this PR do? One or two sentences. -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / cleanup
- [ ] Docs
- [ ] Chore / tooling
## Testing
<!-- How did you verify this works? -->
## Checklist
- [ ] I've tested the affected path manually
- [ ] No `.env` secrets are committed
- [ ] Relevant docs updated (see `../temetro/docs` if needed)
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+93
View File
@@ -0,0 +1,93 @@
# 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"
# QEMU lets the amd64 runner emulate arm64 so the images below build for
# both platforms (Intel + Apple Silicon self-hosters).
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- 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
platforms: linux/amd64,linux/arm64
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
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
# Pull this version's section out of CHANGELOG.md so the release has real,
# human-written notes (the auto "Full Changelog" link is still appended
# below via generate_release_notes). Falls back to a generic line if the
# version has no CHANGELOG entry.
- name: Extract changelog notes
id: notes
run: |
version="${{ steps.meta.outputs.version }}"
awk -v v="$version" '
$0 ~ "^## \\[" v "\\]" {flag=1; next}
/^## \[/ {flag=0}
flag {print}
' CHANGELOG.md | sed '/./,$!d' > release-notes.md
if [ ! -s release-notes.md ]; then
echo "Release $version. See the changelog for details." > release-notes.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release-notes.md
generate_release_notes: true
+11 -1
View File
@@ -6,6 +6,16 @@
"shadcn@latest",
"mcp"
]
},
"XcodeBuildMCP": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"xcodebuildmcp@latest",
"mcp"
],
"env": {}
}
}
}
}
+494
View File
@@ -0,0 +1,494 @@
# 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]
## [0.14.2] — 2026-07-15
### Fixed
- **Docker images build on ARM64 again.** Next 16's default Turbopack production build has no native
bindings for `linux/arm64` in the Alpine image, so `docker compose up --build` failed with
"Turbopack is not supported on this platform". The frontend now builds with Webpack
(`next build --webpack`), which builds on every architecture (`frontend/package.json`).
## [0.14.1] — 2026-07-15
### Fixed
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
failed), the dialog silently closed without pushing. The dialogs now await the link check before
deciding, and an empty change summary can no longer be sent
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
### Changed
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
## [0.14.0] — 2026-07-13
### Changed
- **Patients page filter moved to its own row.** The status filter left the toolbar and now sits on a
dedicated **"Filter"** row directly above the table, so the header stays a clean title + search +
"Add patient" + `⋯` cluster (`frontend/components/patients/patients-view.tsx`).
- **Patient detail sheet header.** The patient name, status badge, and the `⋯` menu now share one
left-aligned row, and the standalone **Edit** button moved to be the first item inside the `⋯` menu
(`frontend/components/patients/patient-detail.tsx`).
- **Settings redesigned with the COSS frame surface.** Every settings section now renders in a
`CardFrame` (header + body) via a new `SettingsFrame` part, replacing the flat `SettingsCard` divs
(`frontend/components/settings/settings-parts.tsx`, `settings-ai.tsx`).
### Added
- **AI Mode: Automatic and Off.** Settings → AI gains two modes beyond API / Local: **Automatic**
(use a cloud API key when set, else fall back to local Ollama) and **Off** (assistant disabled).
Automatic is the new default, so a fresh install shows the setup banner until a provider is wired
(`frontend/lib/ai-settings.ts`, `backend/src/services/ai/{config,provider}.ts`, `types/ai.ts`).
### Fixed
- **AI setup banner now appears when no provider is configured.** Previously the defaulted Ollama URL
counted as "configured," so the "connect an AI model" banner never showed; it now reflects the
actual mode (`frontend/components/chat/ai-setup-notice.tsx`).
- **Chat cards no longer silently vanish.** An unrecognized/renamed streamed data part now renders a
small placeholder instead of nothing (`frontend/components/chat/chat-panel.tsx`).
### Wallet app
- **Documents/files reach the wallet.** Clinic record-update pushes now include attachment metadata,
which the wallet folds into the record so the **Documents** tile counts them and the Documents
screen lists them (`backend/src/services/wallet-updates.ts`, `temetro-app` types + home/documents).
- **Failed pushes are no longer swallowed.** A record update that fails signature verification or
decoding is now logged (and a "couldn't verify" notice is raised) instead of disappearing silently.
- **i18next translation.** The wallet app now uses `i18next` + `react-i18next` + `expo-localization`
(English shipped; other locales can be added later), with the home, record-update inbox, navigation
titles, and the visits/prescriptions-adjacent detail screens extracted to translation keys.
## [0.13.1] — 2026-07-13
### Changed
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
a destructive Delete).
### Wallet app
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
already has a bell) is replaced with **Scan**.
## [0.13.0] — 2026-07-12
### Added
- **Prescription date pickers.** The New Prescription dialog's Start/End date now use a proper COSS
date picker (`frontend/components/ui/date-picker.tsx`, Popover + Calendar) instead of the raw
native date input.
- **Working profile fields.** Settings → Profile **Specialty** (a Select) and **Professional links**
(editable rows) are now wired to persisted preferences — previously they were inert stubs.
- **Patient filters.** The Patients page gains a status filter (all / active / inpatient /
discharged) and its search now also matches conditions and allergies.
- **Language quick-switch.** A language submenu in the sidebar user menu (alongside Theme), applied
immediately with Arabic RTL.
- **Wallet app — home & settings.** The patient app home screen adds a quick-action row (Share
record / My wallet / Notifications) and a recent-activity feed; the settings screen adds an About
section (version, docs, blog) and a privacy footer.
### Changed
- **Record history renders in full.** The patient sheet's Record history replaces the fragile
timeline-separator layout with a continuous-rail list, so every audited change shows completely
(and mirrors correctly under RTL).
- **Update banner + AI setup notice.** The "update available" banner now uses the COSS warning Alert
and stays bottom-right under Arabic; the "connect an AI provider" notice is a clearer, thinner bar
shown above the chat input in active conversations too.
- **Wallet app — bottom sheets.** Polished the shared sheet building blocks (spacing, radii, close
and action affordances).
### Fixed
- **Blog reachable.** `blog.temetro.com` was returning 502 due to a custom-domain target-port
mismatch (Ghost listens on 2368); documented and corrected.
- **Docs freshness.** Corrected stale `CLAUDE.md` claims (the AI chat is real and `@ai-sdk/react` is
installed; the signing/approval flow is built) and reduced the vendored `ai-elements` Base UI
type-drift errors.
## [0.12.1] — 2026-07-10
### Changed
- **Centered wallet-sync stepper.** The in-dialog "Sync to wallet" stepper (`DialogStepper` in
`frontend/components/wallet/wallet-sync-step.tsx`) now uses a proper Stepper primitive
(`components/ui/stepper.tsx`), so the numbered indicators sit centered inline with their labels
instead of the previous left-aligned look.
- **Record history is now a timeline.** The patient sheet's **Record history** section
(`RecordHistory` in `frontend/components/patients/patient-detail.tsx`) renders as a vertical
timeline (`components/ui/timeline.tsx`) — who made the change, an entity-type icon, what happened,
and when — replacing the flat avatar list.
### Performance
- **Landing page: defer the 3D globe.** The Temetro Network globe (three.js) on the marketing site
now mounts only when its section scrolls near the viewport (IntersectionObserver), instead of on
hydration — removing ~730 KB of JS and its main-thread execution from initial page load. (Landing
page lives in the sibling `temetro/landing-page` repo.)
## [0.12.0] — 2026-07-09
### Added
- **In-dialog "Sync to wallet" stepper.** When a clinician adds or edits a record for a
wallet-linked patient — invoice, appointment, prescription, patient demographics, or an AI
scribe note — the create/edit dialog shows a two-step stepper: after saving, step 2 offers to
push the change to the patient's wallet (reusing `pushWalletUpdate` + approval polling). Shared
`useWalletSync` hook and `DialogStepper` / `WalletSyncStep` components under `components/wallet/`.
Patients without a linked wallet see the old close-on-save behaviour.
### Changed
- **Appointment & invoice date pickers block past dates.** The new-appointment date picker disables
days before today; the invoice issue-date picker does too, with an opt-in **Back-date** checkbox
for recording genuinely older invoices (edit mode keeps existing past dates).
- **Patient Portal wallet link is identified by wallet number only.** The portal `link` action no
longer asks the wallet app for a name + file number — it resolves the file the clinic already
paired the wallet number to (`services/portal.ts#linkWallet`), returning a friendly 404 when the
wallet isn't paired yet.
### Removed
- **Stale Settings "Features" section.** Dropped the inert "patient-owned storage" / "require signed
records" toggles (and their unused i18n keys) that did nothing.
## [0.11.0] — 2026-07-09
### Added
- **Patient Portal over the Temetro Network relay + wallet linking.** The wallet app now reaches a
clinic's Patient Portal through the relay instead of a direct HTTP API, so it works from a real
phone. New `services/portal.ts` (clinic info, doctors, availability, wallet linking, conflict-aware
booking, results, downloadable lab files) runs behind a `portal:request` hub handler
(`backend/src/services/relay-client.ts`). A new nullable `patients.wallet_number` column stores the
link, and `walletNumberForPatient` resolves through it so clinic→wallet pushes work after a portal
link (not only after a permanent share). `GET /api/portal/:clinic/link` returns the relay-based
pairing descriptor (clinic signing key + relay URL).
- **Portal "Link my wallet" option.** The Patient Portal kiosk adds a third card that shows a QR the
wallet app scans to link over the relay (`components/portal/portal-kiosk.tsx`).
- **Appointments & invoices reach the wallet.** Clinic→wallet pushes now include the patient's
appointments and invoices in the sealed bundle, so they show up in the wallet app.
- **Clinic location reverse-geocoding.** "Use my current location" now fills address / city /
country (OpenStreetMap Nominatim), not just latitude / longitude (`lib/geocode.ts`).
### Fixed
- **Patient Portal QR was unreachable from a phone.** Settings → Signing now encodes a
`temetro-portal:` pairing URI (relay URL + clinic signing key) instead of a `localhost` API URL, so
the wallet app can actually connect (`components/settings/settings-portal.tsx`).
- **Arabic (RTL) sidebar.** The collapse arrow and notification bell now stack **above** the nav
icons instead of being pinned to the opposite edge; the toggle glyph mirrors and the notifications
popover opens toward the content side (`components/sidebar-02/app-sidebar.tsx`,
`components/ui/sidebar.tsx`, `components/sidebar-02/nav-notifications.tsx`).
## [0.10.0] — 2026-07-07
### Added
- **Patient Portal doctor picker & availability.** The portal now lists the clinic's doctors and
books against a chosen provider, showing only free slots. New public endpoints
`GET /api/portal/:clinic/doctors` and `GET /api/portal/:clinic/availability?provider=&date=`
(display-safe fields only), and `POST /api/portal/:clinic/appointments` accepts an optional
`provider` (`backend/src/routes/portal.ts`). The existing 409 conflict check stays authoritative.
- **Patient Portal links in Settings.** Settings → Signing → Patient Portal adds **open**, **copy
link**, and **QR code** actions (`components/settings/settings-portal.tsx`); the QR carries the
backend base (`?api=`) so the patient wallet app can book natively when it scans it.
- **Clinic location "Use my current location".** The location editor fills map coordinates from the
browser's geolocation (`components/settings/settings-location.tsx`).
- **Wallet app native Patient Portal.** Scanning a clinic's portal QR opens a native booking screen
(doctor list → free-slot picker → confirm) in the patient wallet app.
### Fixed
- **Arabic (RTL) layout.** The sidebar now anchors to the **right** for RTL locales and the toggle
switch mirrors correctly, instead of leaving the shell misaligned
(`components/sidebar-02/app-sidebar.tsx`, `components/ui/switch.tsx`).
- **Wallet app:** record-card **bottom sheet no longer freezes** the app (dropped the per-frame
animated blur overlay for HeroUI's built-in overlay); **Reset wallet** now confirms in a native
HeroUI dialog with Liquid Glass actions; fixed the **white edge flash** on screen transitions in
dark mode; the home/onboarding/register **logo** is now the Temetro mark.
### Changed
- New i18n keys (`settings.portal.*`, geolocation strings) are translated into **all** shipped
locales (en, de, fr, ar, so).
## [0.9.0] — 2026-07-06
### Added
- **Patient blood type & phone number.** The patient record now carries a `bloodType` (e.g. `O+`)
and a `phone` number. Both are shown in the record sheet and chat summary card and are editable in
the add/edit patient form. `phone` is a demographic/contact field (visible to and editable by the
**reception** role); `bloodType` is treated as clinical PHI and is **redacted for reception** (like
allergies/vitals). New columns `patients.phone` / `patients.blood_type` (migration `0033`).
- **Clinic location setting.** A new org-scoped `clinic_settings` table (migration `0034`) stores the
clinic's address (address / city / country) plus optional map coordinates (latitude / longitude),
set in **Settings → Signing → Clinic location** (owner/admin only). New endpoints
`GET /api/clinic/settings` (any clinician) and `PUT /api/clinic/location` (owner/admin). This will
be surfaced in the patient wallet app to show a clinic's location.
### Changed
- New i18n keys for the above are translated into **all** shipped locales (en, de, fr, ar, so), per
the coverage rule now documented in `frontend/CLAUDE.md`.
## [0.8.2] — 2026-07-05
### Fixed
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
`RELAY_URL`. Updated `.env.example` accordingly.
### Changed
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
connected before pre-registering the request, so the routing is set up even if the connection was
opened lazily.
### Fixed
- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing
has no wallet number, so the clinic never sent a `wallet:send` to register the request, and the
relay rejected the scanning device's response as "unknown or expired". The clinic now
**pre-registers** the pairing request with the relay (a new `hub:expect { requestId }` event on
`POST /api/patients/wallet/pair`), so the device's response routes back correctly. On hub
(re)connect the backend re-registers its still-pending requests, so routing also survives a relay
restart. `POST /pair` now also requires the clinic to have joined the network (clear 409 instead of
a dead QR), surfaced in the import dialog.
### Added
- **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each
clinic authenticates to the `/hub` namespace by **signing a challenge with its own Ed25519 clinic
signing key** (`services/signing.ts`) — a per-clinic identity, not a shared password — and the
relay routes every device response back to only the clinic that originated the request (keyed by
`requestId`), so clinics never see each other's traffic. `wallet:online` is fanned out only to
clinics with pending work for that wallet.
- **"Join Temetro Network" opt-in.** A per-clinic toggle in **Settings → Signing** (backed by
`clinic_signing_keys.network_enabled`, `GET`/`PUT /api/signing/network`, owner/admin only). Off by
default; enabling opens the clinic's relay connection, disabling tears it down. Wallet
import/push endpoints return **409** while a clinic hasn't joined. Localised in all five languages.
### Changed
- **The backend keeps one authenticated relay connection per network-enabled org**
(`services/relay-client.ts``connectOrg`/`disconnectOrg`, a `hubs` map keyed by `orgId`), instead
of a single shared-token connection. `emitToWallet`/`sendToWallet` now take an `orgId`, and the
offline-flush (`pendingUpdatesForWallet`) is org-scoped.
- **`RELAY_TOKEN` is now optional/legacy.** Clinics authenticate with their signing key, so an open
relay needs no shared secret; `RELAY_TOKEN` only gates an optional *private* relay.
## [0.7.0] — 2026-07-05
### Added
- **Temetro Network** — a standalone, high-performance **relay** (Rust + Axum + socketioxide) that
connects the backend to patient wallet apps, in its own repo
([github.com/temetro/temetro-network](https://github.com/temetro/temetro-network)) and deployable
on Railway. It replaces the flaky Cloudflare quick-tunnel that used to expose the backend's
embedded `/wallet` Socket.io namespace to phones. The relay is a **dumb, stateless pipe**: a
`/wallet` namespace for devices (challenge/Ed25519-signature auth, room keyed by wallet number)
and a `RELAY_TOKEN`-authenticated `/hub` namespace for the backend. It forwards sealed ciphertext
verbatim, keeps no database, and its only crypto is verifying a device's auth signature (proven
byte-for-byte compatible with `wallet-crypto.ts`).
### Changed
- **The backend is now a client of the relay, not the wallet server.** The `/wallet` Socket.io
namespace was removed from `src/realtime.ts`; a new `src/services/relay-client.ts` connects to the
relay's `/hub` (`emitToWallet` delegates to its `sendToWallet`), handles device responses
(`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) and flushes missed updates on
`wallet:online` — calling the same `wallet-share` / `wallet-updates` services as before. New
`RELAY_URL` + `RELAY_TOKEN` env vars; the wallet-import QR now points at `RELAY_URL`.
## [0.6.0] — 2026-07-04
### Added
- **Read-only FHIR R4 server** — temetro can now be a FHIR **server**, not just a client.
A new endpoint tree at **`/fhir`** (mounted outside `/api`, bearer-only) exposes each
clinic's records as FHIR R4: **Patient**, **Observation** (labs + synthesized vital signs),
**AllergyIntolerance**, **Condition**, **MedicationRequest**, **Encounter** and
**Appointment**, plus an unauthenticated **`GET /fhir/metadata`** CapabilityStatement.
Searches return searchset `Bundle`s with `_count`/`_offset` pagination and self/next/prev
links. Because temetro stores free-text clinical values, every `CodeableConcept` is
**text-only** (no SNOMED/LOINC) and patients carry an **age** extension rather than a
`birthDate` — documented in the CapabilityStatement and API docs.
- **Per-clinic FHIR API keys** — machine-to-machine auth via `Authorization: Bearer tmf_…`.
Keys are created/revoked under **Settings → Integrations → FHIR server** (owner/admin),
**SHA-256-hashed** at rest, and shown **once** at creation. Every FHIR request is
org-scoped (no cross-clinic reads) and written to the activity log with the key name and
result count. New `fhir_api_keys` table, `middleware/fhir-auth.ts`, the
`services/fhir-server/` mapping module (queries, resources, bundle, capability, keys), the
`/fhir` router, and `GET/POST/DELETE /api/integrations/fhir-server/keys`. New `fhirServer`
locale namespace across all five languages.
## [0.5.0] — 2026-07-03
### Added
- **Clinic → wallet record-update push** — a clinician can push an updated record to a
**wallet-linked** patient (a permanent, approved share). The record snapshot is **signed**
with the clinic's Ed25519 key and **sealed** to the wallet's X25519 key (derived from its
Ed25519 wallet number via the birational map — verified byte-for-byte against the wallet's
own derivation), stored `pending`, and delivered over the `/wallet` relay live **and** on
the wallet's next authenticated connect (so an offline phone catches up). The patient
reviews it in a **pending-updates inbox** and approves/denies; the wallet signs its
decision, the backend verifies it, and the on-device record is replaced only on approval.
The wallet **pins** the clinic key (TOFU) and warns on a key change. New
`POST /api/patients/wallet/push`, `GET /api/patients/wallet/{link/:fileNumber,updates,updates/:id}`,
`walletRecordUpdates` table + service, `wallet:update-request` / `wallet:update-response`
relay events, a "Push to wallet" dialog with live status, and a "Sent updates" list under
Settings → Signing. New `walletPush` / `walletUpdatesList` locale namespaces across all five
languages. (The wallet app half ships in the sibling `temetro-app` repo.)
## [0.4.0] — 2026-07-03
### Added
- **Ambient AI visit scribe** — a **Record visit** action on the patient sheet turns a
clinician↔patient conversation into a draft **SOAP** encounter note. Record with the
microphone (`MediaRecorder`, stored as an auditable patient attachment) or paste a
transcript; the backend transcribes via the user's **OpenAI (Whisper)** or **Gemini**
key, de-identifies the transcript + patient context through **Veil**, and the model
drafts a structured note that the clinician **reviews and edits before saving** — the
same write-approval gate as the chat agent. New `POST /api/scribe/{transcribe,draft,save}`
(`backend/src/routes/scribe.ts`, `services/ai/transcribe.ts`), a `veil.redactText()`
free-text redactor, and an `appendEncounter` service that adds one note without touching
the rest of the record. Gated by `patient:write` + the clinic AI policy (reception and
disabled-AI accounts don't see it). New `scribe` locale namespace across all five
languages. Drafting also works with local Ollama from a pasted transcript.
## [0.3.0] — 2026-07-02
### Added
- **Three new interface languages** — Somali (`so`), Arabic (`ar`) and German
(`de`) join English and French, selectable in Settings → Profile → Language.
Each locale carries a full translation of the ~1,660 UI strings, with native
names shown in the selector (Soomaali, العربية, Deutsch).
- **Right-to-left (RTL) support** — selecting Arabic sets `dir="rtl"` on the
document (applied before first paint via an inline script, so no flash), flips
physical spacing/alignment to logical CSS utilities, mirrors directional icons,
and loads an Arabic-capable typeface (IBM Plex Sans Arabic) appended to the
font stack for per-character fallback.
- **Language roams across devices** — the chosen language is persisted to the
per-user `user_settings` preferences and re-applied on sign-in, with
localStorage remaining the offline source of truth.
- **`frontend/scripts/check-locales.mjs`** (+ `npm run check-locales`) — a parity
check that fails on missing/extra keys or `{{placeholder}}` mismatches across
locales and warns when Arabic count-keys lack the full CLDR plural forms.
## [0.2.5] — 2026-07-01
### Fixed
- **Multi-arch Docker images** — the `release` workflow now builds and publishes
`khalidxv/temetro-backend` and `khalidxv/temetro-frontend` for both
`linux/amd64` and `linux/arm64`. Previously the images were amd64-only, so
`docker compose pull` on Apple Silicon failed with *no matching manifest for
linux/arm64/v8* and fell back to building from source.
### Changed
- **Language switcher** in Settings → Profile is now a **select** that asks for
confirmation before switching the interface language, instead of applying the
change instantly on a button tap.
## [0.2.4] — 2026-06-29
### Added
- **Pagination** on the Activity and Invoices pages (10 per page), matching the
Patients page, via a shared `ListPagination` component.
- **French (Français)** interface language, with a language switcher in
Settings → Profile. The choice persists on the device.
- **"Check for updates"** button in Settings → About & updates that forces a
fresh check.
### Changed
- **Update detection** now reads the latest version from **Docker Hub** image
tags (the channel clinics actually pull), falling back to the GitHub release
if Docker Hub is unreachable. This fixes "About & updates" showing *Up to
date* when a newer image was already published.
- **docker compose** host ports are now configurable (`BACKEND_PORT`,
`FRONTEND_PORT`, `ADMINER_PORT`, alongside `POSTGRES_PORT`) so a port clash on
`docker compose up -d` can be resolved from `.env` without editing the file.
## [0.2.3] — 2026-06-29
### Fixed
- **AI chat patient record cards** now render on Google Gemini for name
lookups. "Show me <name>'s medical record" relied on the model chaining
`searchPatients``getPatient`, but Gemini often called `searchPatients`
and then emitted only a canned closing line ("Here's the record.") without
the second tool call — so no card was ever drawn. `searchPatients` now
displays the record card directly when exactly one patient matches, so the
flow no longer depends on a follow-up tool call. (The previous Gemini fix in
0.2.2 only covered the empty-schema list tools.)
## [0.2.2] — 2026-06-28
### Fixed
- **AI chat record cards** now render on Google Gemini. The card-emitting
chat tools (`listAppointments`, `listTasks`, `listPrescriptions`,
`getClinicInfo`, `getAnalytics`, `listInventory`) used an empty parameter
schema; Gemini can't emit a function call for a schema with no properties,
so it printed the call as `tool_code` text instead of invoking the tool —
leaving replies as plain text (e.g. "Show today's schedule" leaked a raw
`<tool_code>` block) with no cards. The tools now share a non-empty schema
so Gemini calls them; other providers are unaffected.
## [0.2.1] — 2026-06-27
### Fixed
- **Patients pagination** controls now render as proper buttons — the prev/next
and page-number controls were unstyled and wrapping (the COSS `PaginationLink`
drops its button styling when given a `render` prop).
- **Patient detail sheet header** reflowed: actions (Download summary / Transfer
/ Edit / Delete) moved to their own wrapping row so the patient name is no
longer truncated.
- **Messages thread** now shows **sender and recipient avatars** alongside the
chat bubbles.
- **Release notes** — the `release` workflow now publishes the matching
`CHANGELOG.md` section as the GitHub Release body (instead of only the
auto-generated "Full Changelog" link).
## [0.2.0] — 2026-06-27
### Added
- **Patients table pagination.** The Patients list now paginates at 10 rows per
page (COSS `Pagination`), so large clinics no longer scroll endlessly.
- **Per-patient record history.** The patient detail sheet shows an audit
timeline of every add/change on that chart. `GET /api/activity/patient/:fileNumber`.
- **Patient summary PDF.** A **Download summary** action on the patient sheet
produces a clean, printable one-page clinical summary (browser "Save as PDF").
- **AI setup notice.** A single, dismissible heads-up appears above the chat
input on a fresh chat when no AI provider (API key or local Ollama) is
configured; it clears itself once you send a message.
- **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
- **Messages thread** rebuilt on the shadcn `Message` / `Bubble` / `Attachment`
components (COSS colour tokens preserved) for a cleaner conversation surface.
- **Patient status badges** now use semantic colours (active → success,
inpatient → info) instead of a flat secondary badge.
- `docker-compose.yml` references the published images (with a build fallback) and
no longer bakes a fixed API URL into the frontend.
### Fixed
- **AI import approval card.** `previewImport` now declares a concrete record
schema so Google Gemini emits a real tool call (and the approval card renders)
instead of dumping a `tool_code`/JSON wall as text. The system prompt also
forbids printing tool calls and re-listing fields.
- **Settings network address** no longer shows a bogus container IP / "Error"
under Docker — the `/api/network` endpoint now skips container-internal
interfaces, and the panel falls back to the helpful LAN hint.
## [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.
+70 -4
View File
@@ -23,10 +23,55 @@ 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
> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Also built:** the **AI chat is real** — the frontend streams from the backend's tool-using
> agent (`POST /api/chat`), not mock replies (see `frontend/CLAUDE.md`).
>
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
> shares. 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.
## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`)
The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the
Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not**
in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g.
on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's
embedded `/wallet` Socket.io namespace to phones.
It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices
(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend
(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed
ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a
device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a
`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet`
now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo,
separately from this one.
> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration
> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it
> in an accessible copy and mirror the tree in with `tar`; drive git there via
> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd.
## Layout
@@ -61,6 +106,11 @@ accurate (e.g. a new backend route needs an `content/docs/api/*.mdx` entry; a UI
in the matching guide; status changes belong in the roadmap). Commit docs changes inside that
repo, separately from this one.
**Every release must also get a dated entry in the docs changelog**
(`content/docs/changelog.mdx`, newest first) — not just the monorepo `CHANGELOG.md`. When you cut a
version (see "Always release after pushing"), add a matching, user-facing section to that page in the
same session so `../temetro/docs` never falls behind the shipped version.
## Running the stack
From `backend/`: ensure a `.env` exists (`cp .env.example .env`, then set `BETTER_AUTH_SECRET` via
@@ -87,6 +137,22 @@ with the area when useful (e.g. `frontend:` / `backend:`). End commit messages w
`.env` files are git-ignored (only `.env.example` is tracked) — never commit real secrets.
### Always release after pushing
When you finish a unit of work and **push to `main`**, you must **also cut a release** — temetro
ships as prebuilt Docker images, so an un-released change never reaches a self-hosted clinic. After
the push:
1. **Bump the version** to the new `X.Y.Z` in **all three** `package.json` files (root,
`backend/`, `frontend/`) — they must stay in sync (`GET /api/version` reports it).
2. **Update `CHANGELOG.md`** (move `Unreleased` notes under a dated `## [X.Y.Z]` heading).
3. **Publish the images to Docker Hub** as `khalidxv/temetro-backend` and
`khalidxv/temetro-frontend`, tagged `X.Y.Z` **and** `latest`. The tag-triggered
`release` workflow does this automatically (`git tag vX.Y.Z && git push origin main --tags`).
See [`RELEASING.md`](./RELEASING.md) for the full checklist. **Never** consider work "done and
pushed" without the version bump + image publish.
## Customized Next.js (frontend)
The `frontend/` app runs a **customized Next.js 16** whose APIs/conventions differ from public docs.
+41
View File
@@ -0,0 +1,41 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes
- Focusing on what is best for the overall community
Examples of unacceptable behavior:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainer at **khalidstudentxv@gmail.com**. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+42
View File
@@ -0,0 +1,42 @@
# Contributing to temetro
Thanks for your interest in contributing! temetro is an open-source clinical tool — contributions that improve reliability, usability, and data privacy are especially welcome.
## Getting started
1. Fork the repository and create a branch from `main`.
2. Follow the setup steps in the root `README.md` to get the stack running locally.
3. Make your changes, test them, and open a pull request.
## Project structure
This is a monorepo. Read the relevant `CLAUDE.md` before working in each area:
- `frontend/` — Next.js chat UI (`frontend/CLAUDE.md`)
- `backend/` — Express + Postgres API (`backend/CLAUDE.md`)
Run `npm` commands from inside the relevant subdirectory, not the root.
## Development workflow
- **Branch naming:** `feat/...`, `fix/...`, `chore/...`, `docs/...`
- **Commits:** one logical change per commit; prefix with `frontend:` or `backend:` when scoped
- **PR size:** keep PRs focused — a feature and its tests, not a feature + unrelated cleanup
## Code style
- TypeScript throughout; no `any` without a comment explaining why
- No dead code, no commented-out blocks
- Comments only when the *why* is non-obvious
## Reporting bugs
Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md). Include steps to reproduce and your environment.
## Security issues
Do **not** open a public issue for security vulnerabilities. See [SECURITY.md](SECURITY.md) for the responsible disclosure process.
## Code of Conduct
This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to uphold it.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Khalid Abdi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+108 -62
View File
@@ -1,97 +1,143 @@
<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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.14.0-success)](./CHANGELOG.md)
## Monorepo layout
![temetro AI chat](./.github/assets/screenshot-chat.png)
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 host ports `5432`, `4000` or `3000` are already in use,
> set `POSTGRES_PORT`, `BACKEND_PORT` and/or `FRONTEND_PORT` (e.g. `5433` /
> `4001` / `3001`) in `backend/.env`. The services still talk to each other on
> their internal ports (`db:5432`, `backend:4000`); only the published host
> ports change.
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).
+56
View File
@@ -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.
+32
View File
@@ -0,0 +1,32 @@
# Security Policy
## Supported versions
Only the latest release on `main` receives security fixes.
## Reporting a vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Email **khalidstudentxv@gmail.com** with:
- A description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept
- Any suggested mitigations (optional)
You will receive an acknowledgement within 48 hours and a status update within 7 days.
## Scope
temetro handles clinical data. Reports in the following areas are treated as high priority:
- Authentication or session bypass
- Unauthorized access to patient records across clinic tenants
- Data injection (SQL, XSS, command injection)
- Exposure of credentials or secrets
## Out of scope
- Vulnerabilities in dependencies that have already been publicly disclosed and are pending an upstream fix
- Theoretical issues with no practical exploit path
- Issues in the patient companion app or blockchain signing layer (not yet built)
+27 -2
View File
@@ -9,6 +9,10 @@ BETTER_AUTH_SECRET=replace-me-with-openssl-rand-base64-32
# Public base URL of THIS backend (used for auth callbacks & cookies).
BETTER_AUTH_URL=http://localhost:4000
# Directory for uploaded patient/lab files. Defaults to ./uploads in local dev;
# in Docker it's a persistent volume (see docker-compose.yml).
UPLOAD_DIR=./uploads
# --- AI ------------------------------------------------------------------
# Key used to encrypt at-rest AI provider API keys (per-user, set in the app's
# Settings → AI). Generate one: openssl rand -base64 32. Rotating it forces
@@ -22,9 +26,30 @@ FRONTEND_URL=http://localhost:3000
PORT=4000
NODE_ENV=development
# Host port Postgres is published on by docker compose. Change it if 5432 is
# already in use on your machine (the app still talks to Postgres internally).
# Host ports docker compose publishes. Change any that are already in use on
# this machine (the services still talk to each other on their internal ports).
POSTGRES_PORT=5432
BACKEND_PORT=4000
FRONTEND_PORT=3000
# --- Temetro Network relay ------------------------------------------------
# The standalone relay (github.com/temetro/temetro-network) that connects this
# backend to patient phones. Deploy it (e.g. on Railway) and point both this
# backend and the wallet app at it. RELAY_URL is the relay's public URL (also
# baked into the QR a patient scans). This clinic authenticates to the relay's
# /hub with its own Ed25519 signing key, so no shared secret is needed.
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
# gates on a shared token (then use the SAME value here and on the relay).
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
# your own relay. Do NOT use http://localhost — inside Docker that's the
# container itself and the relay connection will silently fail.
RELAY_URL=https://network.temetro.com
RELAY_TOKEN=
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
# using the Temetro Network relay. RELAY_URL takes precedence over this.
# PUBLIC_RELAY_URL=http://192.168.1.20:4000
# --- Email (optional) -----------------------------------------------------
# If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the
+1
View File
@@ -6,3 +6,4 @@ dist
npm-debug.log*
.DS_Store
coverage
uploads
+12
View File
@@ -60,6 +60,18 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
authenticates by signing the relay's `hub:challenge` with its clinic signing key
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
## Gotchas / conventions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Khalid Abdi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10 -1
View File
@@ -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.
+27
View File
@@ -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
+38 -8
View File
@@ -1,20 +1,36 @@
# 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
#
# Optional DB browser (Adminer) lives behind a profile:
# docker compose --profile tools up adminer # http://localhost:8080
#
# Host ports are configurable to avoid clashing with software already running on
# this machine. Override any of them in a .env file (or inline), e.g.:
# POSTGRES_PORT=5433 BACKEND_PORT=4001 FRONTEND_PORT=3001 docker compose up -d
# Only the published host port changes; the services still talk to each other on
# their internal ports (db:5432, backend:4000).
services:
db:
@@ -36,6 +52,7 @@ services:
retries: 10
backend:
image: khalidxv/temetro-backend:${TEMETRO_VERSION:-latest}
build:
context: .
restart: unless-stopped
@@ -51,7 +68,14 @@ services:
BETTER_AUTH_URL: http://localhost:4000
FRONTEND_URL: http://localhost:3000
PORT: "4000"
# Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL
# to your deployed relay's public URL and RELAY_TOKEN to the shared secret
# you configured on it — both are required for patient-wallet import.
RELAY_URL: ${RELAY_URL:-}
RELAY_TOKEN: ${RELAY_TOKEN:-}
NODE_ENV: production
# Uploaded patient/lab files live here, on the temetro_uploads volume.
UPLOAD_DIR: /var/lib/temetro/uploads
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_USER: ${SMTP_USER:-}
@@ -60,21 +84,26 @@ services:
volumes:
# Persists auto-generated secrets so they stay stable across restarts.
- temetro_secrets:/var/lib/temetro
# Persists uploaded files across restarts/rebuilds.
- temetro_uploads:/var/lib/temetro/uploads
ports:
- "4000:4000"
# Host port is configurable to avoid clashing with an existing service.
- "${BACKEND_PORT:-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"
# Host port is configurable to avoid clashing with an existing service.
- "${FRONTEND_PORT:-3000}:3000"
adminer:
image: adminer:5
@@ -83,8 +112,9 @@ services:
depends_on:
- db
ports:
- "8080:8080"
- "${ADMINER_PORT:-8080}:8080"
volumes:
temetro_pgdata:
temetro_secrets:
temetro_uploads:
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"file_number" text,
"lab_key" text,
"filename" text NOT NULL,
"mime_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"storage_path" text NOT NULL,
"uploaded_by_user_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_user_id_user_id_fk" FOREIGN KEY ("uploaded_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "attachments_org_file_idx" ON "attachments" USING btree ("organization_id","file_number");
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "integrations" (
"organization_id" text NOT NULL,
"type" text NOT NULL,
"endpoint" text DEFAULT '' NOT NULL,
"credentials" text,
"enabled" boolean DEFAULT false NOT NULL,
"status" text DEFAULT 'unconfigured' NOT NULL,
"last_sync_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "integrations_organization_id_type_pk" PRIMARY KEY("organization_id","type")
);
--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
@@ -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");
+11
View File
@@ -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");
+14
View File
@@ -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");
+2
View File
@@ -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;
+7
View File
@@ -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
);
+33
View File
@@ -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");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "wallet_share_requests" ALTER COLUMN "wallet_number" DROP NOT NULL;
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "wallet_record_updates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"created_by" text NOT NULL,
"file_number" text NOT NULL,
"wallet_number" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload_sealed" text NOT NULL,
"clinic_signature" text NOT NULL,
"clinic_public_key" text NOT NULL,
"clinic_fingerprint" text NOT NULL,
"changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"resolved_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_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_record_updates" ADD CONSTRAINT "wallet_record_updates_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wallet_updates_org_idx" ON "wallet_record_updates" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "wallet_updates_wallet_idx" ON "wallet_record_updates" USING btree ("wallet_number");
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "fhir_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_used_at" timestamp,
"revoked_at" timestamp,
CONSTRAINT "fhir_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_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 "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_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 "fhir_api_keys_org_idx" ON "fhir_api_keys" USING btree ("organization_id");
@@ -0,0 +1 @@
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "patients" ADD COLUMN "phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "blood_type" text DEFAULT '' NOT NULL;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "clinic_settings" (
"organization_id" text PRIMARY KEY NOT NULL,
"address" text DEFAULT '' NOT NULL,
"city" text DEFAULT '' NOT NULL,
"country" text DEFAULT '' NOT NULL,
"latitude" double precision,
"longitude" double precision,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clinic_settings" ADD CONSTRAINT "clinic_settings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE "patients" ADD COLUMN "wallet_number" text;
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
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
File diff suppressed because it is too large Load Diff
+105
View File
@@ -148,6 +148,111 @@
"when": 1781629630102,
"tag": "0020_freezing_tomas",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1781801972208,
"tag": "0021_milky_blur",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"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
},
{
"idx": 30,
"version": "7",
"when": 1783093188246,
"tag": "0030_medical_blur",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783117115021,
"tag": "0031_stiff_gateway",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1783263738631,
"tag": "0032_closed_dakota_north",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1783362745730,
"tag": "0033_ambitious_reavers",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783363217049,
"tag": "0034_chunky_blacklash",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
}
]
}
+49
View File
@@ -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"
+197 -16
View File
@@ -1,28 +1,34 @@
{
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.84",
"@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",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0",
"nanoid": "^5.1.11",
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -2181,6 +2187,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",
@@ -2246,7 +2267,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -2257,7 +2277,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2276,7 +2295,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -2288,7 +2306,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -2301,9 +2318,17 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -2339,21 +2364,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2363,7 +2385,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -2419,6 +2440,12 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2711,7 +2738,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/bundle-name": {
@@ -2730,6 +2756,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2879,6 +2916,21 @@
"node": ">=18"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -3288,6 +3340,40 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -4027,6 +4113,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
@@ -4508,7 +4656,6 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -4589,7 +4736,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -4836,6 +4982,21 @@
"ws": "~8.20.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
@@ -4931,11 +5092,18 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
@@ -5537,6 +5705,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -5601,7 +5775,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/vary": {
@@ -5656,6 +5829,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.14.2",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
@@ -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,16 +26,22 @@
"@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",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0",
"nanoid": "^5.1.11",
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
+13
View File
@@ -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
}
}
+48
View File
@@ -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
+86
View File
@@ -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
View File
@@ -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,
});
},
},
+42
View File
@@ -0,0 +1,42 @@
import {
index,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Files uploaded against a patient record (and optionally a specific lab
// result). Scoped to a clinic (organization). The bytes live on disk under
// UPLOAD_DIR (see src/services/attachments.ts); this table only holds metadata
// plus the relative `storagePath`.
// fileNumber → the patient's MRN this file belongs to.
// labKey → set when the file documents a specific lab result.
export const attachments = pgTable(
"attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
fileNumber: text("file_number"),
labKey: text("lab_key"),
filename: text("filename").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
storagePath: text("storage_path").notNull(),
uploadedByUserId: text("uploaded_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("attachments_org_file_idx").on(
table.organizationId,
table.fileNumber,
),
],
);
+25
View File
@@ -0,0 +1,25 @@
import { doublePrecision, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Per-clinic (organization) settings. Currently holds the clinic's physical
// location — a free-text address plus optional map coordinates — set in
// Settings → Location by an owner/admin and surfaced to patients in the wallet
// app later (e.g. a map pin for a clinic that shared a record). One row per org
// (PK = organizationId), mirroring `clinic_signing_keys`.
export const clinicSettings = pgTable("clinic_settings", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
address: text("address").notNull().default(""),
city: text("city").notNull().default(""),
country: text("country").notNull().default(""),
// Optional map coordinates (WGS84). Null until the clinic sets them.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+19
View File
@@ -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(),
});
+30
View File
@@ -0,0 +1,30 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Per-organization API keys for the read-only FHIR R4 server (`/fhir`). These
// are machine-to-machine credentials (no Better Auth session): a caller sends
// `Authorization: Bearer tmf_<secret>` and every query is scoped to the owning
// clinic. Only the SHA-256 *hash* of the secret is stored — the plaintext key is
// shown once at creation and never again. Revoking sets `revokedAt` (kept for
// audit rather than hard-deleted).
export const fhirApiKeys = pgTable(
"fhir_api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// Hex SHA-256 of the full `tmf_…` secret. Unique so a lookup is a single
// indexed probe and two keys can never collide.
keyHash: text("key_hash").notNull().unique(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
},
(t) => [index("fhir_api_keys_org_idx").on(t.organizationId)],
);
+10
View File
@@ -11,6 +11,16 @@ 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 "./clinic-settings.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+40
View File
@@ -0,0 +1,40 @@
import {
boolean,
pgTable,
primaryKey,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// External healthcare-system integrations, configured per clinic. One row per
// (organization, type):
// fhir → HL7/FHIR lab-system connection (lab results in/out)
// eprescribe → NCPDP SCRIPT e-prescribing to pharmacies
// claims → X12 837/835 insurance claims clearinghouse
// `credentials` is an encrypted JSON blob (API key / bearer token / submitter
// IDs — shape depends on the type); `endpoint` is the base URL the clinic
// points the integration at (a vendor sandbox or production gateway).
export const integrations = pgTable(
"integrations",
{
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
type: text("type").notNull(),
endpoint: text("endpoint").notNull().default(""),
credentials: text("credentials"),
enabled: boolean("enabled").notNull().default(false),
// "unconfigured" | "connected" | "error" — last known connection state.
status: text("status").notNull().default("unconfigured"),
lastSyncAt: timestamp("last_sync_at"),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
primaryKey({ columns: [table.organizationId, table.type] }),
],
);
+52
View File
@@ -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)],
);
+14
View File
@@ -36,6 +36,11 @@ export const patients = pgTable(
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
// Contact + clinical demographics. `phone` is a contact/registration field
// (reception may read/write it); `bloodType` is clinical (redacted for the
// reception role, like allergies/vitals).
phone: text("phone").notNull().default(""),
bloodType: text("blood_type").notNull().default(""),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
@@ -54,6 +59,15 @@ 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"),
// The patient's wallet number (tmw_…) once they link their wallet from the
// Patient Portal. Lets clinic→wallet pushes and portal actions resolve to
// this file directly (services/portal.ts, wallet-updates.ts). Nullable.
walletNumber: text("wallet_number"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
+26
View File
@@ -0,0 +1,26 @@
import { boolean, 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(),
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
// Network" in Settings → Signing). Off by default: only when enabled does the
// backend open this clinic's relay hub connection and expose wallet features.
// The relay identity *is* this signing key, so the flag lives on the same row.
networkEnabled: boolean("network_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
rotatedAt: timestamp("rotated_at"),
});
+31
View File
@@ -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,
),
],
);
+5
View File
@@ -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`
+53
View File
@@ -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),
],
);
+53
View File
@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
export type WalletUpdateStatus =
| "pending"
| "delivered"
| "approved"
| "denied";
// One row per clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient they can push the updated record to the patient's app;
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
// the device is connected, and again on the wallet's next authenticated connect
// (so an offline phone still receives it). The patient reviews the change,
// verifies the clinic signature, and approves/denies in-app — only then is the
// on-device record replaced. The clinic polls `status` for delivery/approval.
export const walletRecordUpdates = pgTable(
"wallet_record_updates",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdBy: text("created_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
walletNumber: text("wallet_number").notNull(),
status: text("status")
.$type<WalletUpdateStatus>()
.notNull()
.default("pending"),
// base64 sealed box of the full updated patient snapshot (sealed to the
// wallet's derived X25519 key).
payloadSealed: text("payload_sealed").notNull(),
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
clinicSignature: text("clinic_signature").notNull(),
clinicPublicKey: text("clinic_public_key").notNull(),
clinicFingerprint: text("clinic_fingerprint").notNull(),
// Human-readable summary of what changed (shown in the wallet inbox).
changes: jsonb("changes").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_updates_org_idx").on(t.organizationId),
index("wallet_updates_wallet_idx").on(t.walletNumber),
],
);
+34
View File
@@ -16,8 +16,39 @@ const schema = z.object({
.string()
.min(1)
.default("dev-insecure-ai-key-change-me"),
// Directory where uploaded patient/lab files are stored on disk. Back this
// with a persistent volume in production (see docker-compose.yml).
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(),
// Temetro Network relay (github.com/temetro/temetro-network). Both this
// backend and patient phones connect to it; it routes the encrypted wallet
// messages between them. RELAY_URL is the relay's public URL (also baked into
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
// now *optional/legacy* — set it only for a private relay that also gates on a
// shared token (must then match the relay's RELAY_TOKEN).
//
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
// override only when running your own relay. (A `localhost` default silently
// fails inside Docker, where localhost is the container itself.)
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
RELAY_TOKEN: z.string().default(""),
// 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"])
@@ -62,6 +93,9 @@ if (env.NODE_ENV === "production") {
);
process.exit(1);
}
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
// no warning needed.
}
export const isProd = env.NODE_ENV === "production";
+67 -1
View File
@@ -6,33 +6,53 @@ 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";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { clinicRouter } from "./routes/clinic.js";
import { conversationsRouter } from "./routes/conversations.js";
import { dispensesRouter } from "./routes/dispenses.js";
import { fhirRouter } from "./routes/fhir.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 { scribeRouter } from "./routes/scribe.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 { initRelayClient } from "./services/relay-client.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,
}),
);
@@ -62,7 +82,17 @@ 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/clinic", clinicRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
@@ -74,10 +104,20 @@ 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/scribe", scribeRouter);
app.use("/api/integrations", integrationsRouter);
app.use("/api/portal", portalRouter);
app.use("/api/auth-helpers", authHelpersRouter);
// Read-only FHIR R4 server, mounted OUTSIDE /api. Bearer-only (per-clinic API
// keys), no Better Auth session/cookie coupling. Errors are FHIR
// OperationOutcomes, not our standard error JSON.
app.use("/fhir", fhirRouter);
app.use(notFound);
app.use(errorHandler);
@@ -86,10 +126,24 @@ app.use(errorHandler);
const server = createServer(app);
initRealtime(server);
// Connect to the Temetro Network relay (the device-facing hub). Patient phones
// no longer connect to this backend directly — they connect to the relay, and
// we push to / receive from them over its /hub namespace.
initRelayClient();
// 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})`);
console.log(` • patients: /api/patients`);
console.log(` • files: /api/attachments`);
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
@@ -104,4 +158,16 @@ server.listen(env.PORT, () => {
console.log(` • settings: /api/settings`);
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(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`);
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
console.log(` • wallet: /api/patients/wallet (via Temetro Network relay: ${env.RELAY_URL})`);
});
// 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);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
// is the plaintext key for the *currently selected* provider — it is encrypted
// before storage and never echoed back.
export const aiConfigInputSchema = z.object({
mode: z.enum(["api", "local"]).optional(),
mode: z.enum(["api", "local", "auto", "off"]).optional(),
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
ollamaBaseUrl: z.string().url().optional(),
ollamaModel: z.string().min(1).max(120).optional(),
+126 -26
View File
@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More