個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Thyroid health tracking app — symptom log, lab results, medications, appointments. TypeScript/Express/GraphQL + React, self-hosted (Docker, Traefik, Raspberry Pi).
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
A web app for tracking thyroid health, inspired by the Clue app.
Stack: TypeScript · Express · GraphQL (additive) · Prisma · PostgreSQL · React · Recharts · Docker
The frontend container only serves static files (nginx); all /api/* requests are proxied
to backend (see frontend/nginx.conf), which is the only service that talks to PostgreSQL
via Prisma. The browser therefore only ever sees a single origin, which avoids any檢視完整 README ↗
A web app for tracking thyroid health, inspired by the Clue app.
Stack: TypeScript · Express · GraphQL (additive) · Prisma · PostgreSQL · React · Recharts · Docker
| Module | Detail |
|---|---|
| Daily log | Energy, mood, anxiety, brain fog, 11 thyroid symptoms, medication taken, physical measurements (weight/heart rate/sleep, syncable via Google Health, e.g. Pixel Watch) |
| Lab results | TSH, FT4, FT3, Anti-TPO, Anti-TG, deficiencies (Ferritin, Vit D, B12…) with trend charts; PDF import, Pro accounts only after 3 free trial imports (batch, password-protected PDFs supported, review before adding, encrypted storage, optional AI fallback) |
| Medications | Treatment management (Levothyroxine, etc.), dosage, frequency, adherence |
| Appointments | Medical calendar with reminders, statuses, specialized types |
| Dashboard | Medication streak, adherence, averages, next appointment, TSH history |
| Profile | Diagnosis, thyroid status, TSH/FT4/FT3 target ranges set by your doctor |
| Email reminders | Today's log, medication doses, next blood test, appointments — all off by default, in local time |
| My data (GDPR) | Full JSON export of one's data (lab PDFs included) and permanent account deletion (Stripe subscription cancelled) |
flowchart LR
UI["Browser"]
TR["Traefik<br/>reverse proxy (:8000)"]
subgraph Host["Docker host (docker-compose.yml)"]
FE["frontend<br/>nginx + React/Vite build<br/>:80"]
BE["backend<br/>Express + TypeScript<br/>:3001"]
FE -- "proxy /api/*" --> BE
end
DB[("PostgreSQL 17<br/>shared native instance (outside Docker)")]
EXT["Google OAuth · Resend"]
UI -- "HTTPS · cloudflared" --> TR
TR -- "Host: thyrotrack.alithiel31.dev" --> FE
BE -- "host.docker.internal:5432" --> DB
BE -. "OIDC (Google login) / email sending" .-> EXT
The frontend container only serves static files (nginx); all /api/* requests are proxied
to backend (see frontend/nginx.conf), which is the only service that talks to PostgreSQL
via Prisma. The browser therefore only ever sees a single origin, which avoids any client-side
CORS configuration in production — CORS_ORIGIN/FRONTEND_URL remain a safety net in case the
backend is called directly.
No port is published on the host: frontend joins the traefik-net network (created by
Traefik, declared external: true in docker-compose.yml) and carries traefik.* labels that
route thyrotrack.alithiel31.dev to its internal port 80. Traefik is a service shared across
several projects on this host, not managed by this repo. PostgreSQL isn't a service of this
docker-compose.yml either: it's a shared instance, also common to several projects on this
host, reached from the backend container via host.docker.internal (see extra_hosts in
docker-compose.yml).
git clone <url>
cd thyro-track
cd backend && npm install && cd ..
cd frontend && npm install && cd ..
cp backend/.env.example backend/.env
# Edit backend/.env with your DATABASE_URL and JWT_SECRET
Without GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, GET /api/auth/oidc/google simply responds
501 and the Google button stays inert — the rest of the app works normally. To enable it:
openid, email, profile scopes.http://localhost:5173 (the frontend URL).http://localhost:3001/api/auth/oidc/google/callback (must exactly match GOOGLE_REDIRECT_URI — this is the backend's URL, not the frontend's).backend/.env:
GOOGLE_CLIENT_ID="xxxxxxxx.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="xxxxxxxx"
In production, update the origins/redirect URIs with the real domain and adjust
GOOGLE_REDIRECT_URI (+ APP_URL) accordingly.
A feature distinct from "Sign in with Google" above: it links a connected device (e.g. a
Pixel Watch) via the Google Health API to
automatically pre-fill Weight, Heart rate and Sleep hours in the daily log (a ⌚
badge marks a synced value). Without GOOGLE_HEALTH_CLIENT_ID, the "Google Health" card in the
Profile page responds 501 and stays inactive.
Two distinct authentication flows are involved:
GOOGLE_HEALTH_CLIENT_ID/SECRET): each user authorizes ThyroTrack to read
their health data — this is the "Link" button in the Profile page.GOOGLE_HEALTH_SERVICE_ACCOUNT_KEY): manages a
single project-wide webhook subscriber (not per user), created once at backend startup.
With an AUTOMATIC policy, Google then automatically routes notifications for every
consenting user to this one subscriber — no individual subscription is needed.⚠️
fetchDailyMetrics(reading measurements) is still partly best-effort. The host, the version (health.googleapis.com/v4), the OAuth scopes and the whole webhook subscription model were confirmed by reading the official documentation directly. The exact shape of the JSON body returned by thedataTypes/{type}/dataPointsendpoints (used to read values), however, could not be verified — the parsing inbackend/src/lib/googleHealth.ts#fetchDailyMetricsis a best guess, to be adjusted if needed once real data has been observed.
1. Project and API
On the Google Cloud Console, enable the Google Health
API on the project (the same one as "Sign in with Google", or a dedicated one). Note the
project's number (visible on the project's home page — not its text ID, Google returns a
400/403 error otherwise) for GOOGLE_HEALTH_PROJECT_NUMBER.
2. OAuth client (user login)
http://localhost:3001/api/integrations/google-health/callback
(must match GOOGLE_HEALTH_REDIRECT_URI).backend/.env (GOOGLE_HEALTH_CLIENT_ID,
GOOGLE_HEALTH_CLIENT_SECRET), and generate an encryption key for the stored tokens:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
to put in TOKEN_ENCRYPTION_KEY (required as soon as GOOGLE_HEALTH_CLIENT_ID is set, the
server refuses to start in production otherwise).3. Service account (project-wide webhook subscriber)
GOOGLE_HEALTH_SERVICE_ACCOUNT_KEY (not a
file path — the env var holds the JSON itself).GOOGLE_HEALTH_WEBHOOK_SECRET:
node -e "console.log('Bearer ' + require('crypto').randomBytes(24).toString('hex'))"
This secret is sent to Google when the subscriber is created and echoed back by Google in
every notification — that's what lets the backend verify their authenticity.GOOGLE_HEALTH_PROJECT_NUMBER (see step 1).4. Important: public HTTPS
POST /api/webhooks/google-health must be reachable by Google over public HTTPS — this won't
work with localhost. A real, deployed domain is required for the subscriber to be created
successfully: Google performs a synchronous double-check of the endpoint at creation time (an
authenticated request that must respond 200/201, and an unauthenticated one that must respond
401/403), and subscriber creation fails if either one fails.
Once everything is filled in, restart the backend: it creates the subscriber automatically at
startup (check the logs to confirm Google Health webhook subscriber "thyrotrack-webhook" created.).
In production, update GOOGLE_HEALTH_REDIRECT_URI with the real domain.
cd backend
npx prisma migrate dev --name init
npx prisma generate
npm run db:seed # Creates a demo account: [email protected] / demo1234
In two separate terminals:
cd backend && npm run dev # http://localhost:3001
cd frontend && npm run dev # http://localhost:5173
This project's real-world deployment goes through the root docker-compose.yml: two services
(Express backend, frontend served by nginx) built from backend/Dockerfile and
frontend/Dockerfile. As of 2026-08-22, PostgreSQL is no longer a service in this file —
the backend connects to a PostgreSQL instance shared with other projects on the same host, and
the frontend no longer exposes a port: it's routed through a Traefik reverse proxy, itself
shared as well (see the architecture diagram above). These two external dependencies are
therefore prerequisites for any deployment with this file as-is:
traefik-net network) already running on the host.Deploying on a host without a shared Traefik or PostgreSQL (e.g. a fresh host, or an isolated test): this
docker-compose.ymlis no longer self-contained as-is. You'd need to either reintroduce a dedicatedpostgresservice and republish a port onfrontend(ports: ["8082:80"], removing thetraefik.*labels and thetraefik-netnetwork), or deploy your own Traefik instance. This isn't documented here since it isn't the configuration actually used for this project — ask if you need it.
cp .env.example .env
# Fill in DB_PASSWORD: the password for the PostgreSQL application role
# (e.g. openssl rand -hex 24), already created on the shared instance.
cp backend/.env.example backend/.env
# Fill in JWT_SECRET (32+ characters), RESEND_API_KEY, and
# GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET if "Sign in with Google" is used.
# DATABASE_URL and FRONTEND_URL are already set in docker-compose.yml —
# adapt them to your own domain and PostgreSQL host before deploying.
docker compose up -d # arm64 images published to GHCR by CI
# or, to build from source (other architecture, local changes):
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
Production on Caesura (Infisical): secrets (
DB_PASSWORD,PDF_ENCRYPTION_KEY,ANTHROPIC_API_KEY) are not read from.env: run./deploy.shinstead, which injects them from Infisical (see environment.md), backs up the database, deploys the latestmain(or--tag sha-<commit>to roll back) and checks the public/health. Optional PDF import: setPDF_ENCRYPTION_KEY(andANTHROPIC_API_KEYfor the AI fallback) in.env. Optional card payments for the Pro plan:STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID(see Pro access and promo codes).
The migrate service runs prisma migrate deploy before every backend (re)creation, and the
backend only starts once the migrations have succeeded. The backend joins PostgreSQL via host.docker.internal (see extra_hosts in
docker-compose.yml) — the database must therefore already exist with the application role
expected by DATABASE_URL. The frontend is routed by Traefik based on its traefik.* labels
(a Host rule on the configured domain), the backend stays internal on 3001.
docker compose exec backend npm run db:seed
Generate a JWT_SECRET:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
thyro-track/
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma # Full data models
│ │ └── seed.ts # Demo data
│ ├── src/
│ │ ├── index.ts # Express entry point
│ │ ├── lib/ # Prisma client, i18n, logger, email (Resend), OIDC
│ │ ├── middleware/
│ │ │ ├── auth.ts # JWT middleware
│ │ │ ├── admin.ts
│ │ │ ├── asyncHandler.ts
│ │ │ └── errorHandler.ts
│ │ ├── routers/ # Express route declarations (*.router.ts)
│ │ ├── controllers/ # Business logic + Zod validation (*.controller.ts)
│ │ └── graphql/ # POST /graphql endpoint, additive to the REST API — see backend/docs/graphql.md
│ ├── docs/
│ │ └── graphql.md # Why/how of the GraphQL layer (N+1, auth, out of scope)
│ ├── Dockerfile
│ └── package.json
│
├── frontend/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── DashboardPage # Overview
│ │ │ ├── LogPage # Daily log (Clue-style)
│ │ │ ├── LabResultsPage # Lab results + charts
│ │ │ ├── MedicationsPage # Treatments
│ │ │ ├── AppointmentsPage
│ │ │ └── ProfilePage
│ │ ├── lib/
│ │ │ ├── api.ts # Typed axios client
│ │ │ ├── store.ts # Auth state (Zustand)
│ │ │ └── utils.ts # Date/color helpers
│ │ └── types/index.ts # Shared types + constants
│ ├── Dockerfile
│ └── package.json
│
├── docker-compose.yml # Backend + Frontend (nginx) — PostgreSQL and Traefik are external
├── .env.example # Variables read by docker-compose.yml
└── LICENSE
User ──┬── UserProfile (diagnosis, target ranges)
├── DailyEntry[] (daily log — weight/HR/sleep with source MANUAL|GOOGLE_HEALTH)
│ └── SymptomLog[] (custom symptoms)
├── LabResult[] (TSH, FT4, FT3, antibodies, deficiencies)
├── Medication[] (treatments)
├── Appointment[] (medical appointments)
├── GoogleHealthConnection (encrypted tokens, Pixel Watch sync...)
└── NotificationSetting
POST /api/auth/register
POST /api/auth/login
GET /api/auth/me
GET /api/auth/oidc/google (redirects to Google — OAuth2 + OpenID Connect)
GET /api/auth/oidc/google/callback
GET /api/entries?from=&to=
GET /api/entries/:date
POST /api/entries (upsert by date)
DELETE /api/entries/:date
GET /api/lab-results
POST /api/lab-results
PUT /api/lab-results/:id
DELETE /api/lab-results/:id
POST /api/lab-results/import (multipart PDFs → one draft per file)
POST /api/lab-results/import/:docId/confirm (reviewed values → creates the result)
DELETE /api/lab-results/import/:docId (discard a draft)
GET /api/lab-results/documents/:docId (download the stored PDF, owner only)
GET /api/pdf-passwords (labels only — never the secrets)
POST /api/pdf-passwords
DELETE /api/pdf-passwords/:id
DELETE /api/pdf-passwords (revoke all)
PUT /api/profile/ai-extraction-consent (opt in / out of the AI reading; optional `always`)
POST /api/billing/checkout (Pro plan: returns the Stripe payment page URL)
POST /api/billing/portal (Stripe customer portal)
POST /api/billing/redeem (activates Pro access with a promo code)
POST /api/webhooks/stripe (Stripe webhook, signature verified)
GET|POST|PATCH /api/admin/promo-codes (ADMIN: manage promo codes)
GET /api/medications
POST /api/medications
PUT /api/medications/:id
DELETE /api/medications/:id
GET /api/appointments
POST /api/appointments
PUT /api/appointments/:id
DELETE /api/appointments/:id
GET /api/profile
PUT /api/profile
GET /api/profile/notifications (email reminder settings)
PUT /api/profile/notifications
GET /api/account/export (GDPR export: all data as JSON)
DELETE /api/account (permanent deletion: { password } or { confirmEmail })
GET /api/analytics/overview?days=90
GET /api/analytics/symptoms?days=30
POST /api/integrations/google-health/link (starts the Pixel Watch/Google Health connection)
GET /api/integrations/google-health/callback
DELETE /api/integrations/google-health/link
POST /api/integrations/google-health/resync (re-reads the last N days, 30 by default, 90 max)
POST /api/webhooks/google-health (Google notifications + subscriber validation handshake)
POST /graphql (additive to the REST API above, doesn't replace it — see backend/docs/graphql.md)
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 |
| API | Express 4 + TypeScript |
| GraphQL API | Apollo Server 5 + DataLoader (additive to REST, technical demo — see backend/docs/graphql.md) |
| ORM | Prisma 5 |
| Database | PostgreSQL |
| Auth | JWT (jsonwebtoken) + bcryptjs, OAuth2 + OpenID Connect (Google, via openid-client) |
| Validation | Zod |
| Frontend | React 18 + Vite |
| State | Zustand + TanStack Query |
| Charts | Recharts |
| Routing | React Router 6 |
| Deployment | Docker Compose (self-hosted, Raspberry Pi) |
| Reverse proxy | Traefik (shared, traefik-net) |
| Public access | cloudflared (tunnel) |
| Database (infra) | PostgreSQL 17, shared native instance (outside Docker) |
| Guide | Content |
|---|---|
docs/architecture.md | Request path, authentication, Google Health sync, backend layers, data model, repository structure |
docs/environment.md | Every environment variable, for Docker Compose and for the backend |
docs/development.md | Run it locally, database migrations, full route list, tests, CI |
backend/docs/graphql.md | The additive GraphQL layer (in French) |
docs/handover-guide.md | What runs where, which accesses are needed, how to rebuild from scratch, known risks |
docs/troubleshooting.md | Incidents already encountered, with the diagnostic process |
CONTRIBUTING.md · SECURITY.md · SUPPORT.md · CODE_OF_CONDUCT.md | Community files |
Each guide has a French version (*.fr.md).
Manual entry of lab results and the export stay free. PDF lab report import is reserved for
Pro accounts: 3 free imports for life, then a Stripe subscription (cap of 25 imports per month). An
ADMIN account is always Pro. The server is the authority (402/429); the interface only guides.
Create a promo code (ADMIN account, JWT in $TOKEN):
curl -X POST https://thyrotrack.alithiel31.dev/api/admin/promo-codes \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"durationDays": 90, "maxRedemptions": 5, "note": "family"}'
# => { "code": "THYRO-XXXX-XXXX", ... } ; "code" can also be supplied (e.g. "MAMAN")
The person enters the code under Profile → Pro subscription. Deactivate a code with
PATCH /api/admin/promo-codes/:id and {"active": false}. Granting access directly in the database:
UPDATE users SET "proUntil" = '2027-12-31', "proSource" = 'GRANT' WHERE email = '...';
Stripe: create a recurring product (→ STRIPE_PRICE_ID) and a webhook endpoint
https://<domain>/api/webhooks/stripe subscribed to checkout.session.completed and
customer.subscription.created|updated|deleted (→ STRIPE_WEBHOOK_SECRET), enable the customer
portal in the Stripe dashboard, then set the three variables in Infisical. Stripe only receives the
account's email, never any health data. Details: docs/environment.md.
See CONTRIBUTING.md for the development setup, how to reproduce CI
locally, and the PR format. If you hit an issue, docs/troubleshooting.md
documents incidents already encountered on this project (and their diagnosis).
Notable changes are documented in CHANGELOG.md (French only).
Private project — see LICENSE.