PyHealth
헬스케어 AI용 딥러닝 툴킷, EHR·임상 텍스트·신호·영상 지원, 30여 개 모델·재현 파이프라인 제공
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기PT news
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A real implementation of the "PT News" design (project/PT News.dc.html in the repo root),
built as a Next.js 16 (App Router) app with a SQLite-backed persistence layer and live
data sourcing, in place of the design prototype's in-memory-only state and static seed
content.README 전문 보기 ↗Limbic Center is the physical therapy platform at limbic.center: research and news, clinician tools, student study surfaces, and health & wellness. It is a Next.js 16 (App Router) app with a SQLite-backed persistence layer and live data sourcing.
src/app/api/cron/; and a
few session-authenticated fetches (/api/assignments, /api/navigation-badges,
/api/health-sync).@prisma/adapter-libsql driver adapter) for
persistence: user profiles, license/CE info, saved articles, and clinician-authored
home exercise programs survive restarts. Accounts sign in with email and password,
Google, or a named guest session; a PT license is stored on the account after Profile
verification and is not the sign-in key. The libSQL adapter works identically against
a local file in development and a hosted Turso database in
production — same schema, same queries, no branching — because a plain local SQLite
file (what the app defaults to) doesn't survive on most serverless hosts, whose
filesystems reset between requests.src/styles/ (not Tailwind) — design-system tokens and shared primitives
(.card, .btn, .tag, …) load globally; feature sheets load from the route layouts
that need them. See docs/css.md.npm install
cp .env.example .env # local SQLite; see that file for optional API keys
npx prisma migrate deploy # or `npx prisma migrate dev` in development
npm run dev
Visit http://localhost:3000. Create an account with email and password, continue with
Google when those OAuth variables are set, or continue as a guest (a name, plus acceptance
of the terms). A PT license is added later from Profile and reviewed in the admin license
queue. Signed-in readers and guests can read, search, and save. Retracted Articles
requires a license on file. Exercise Programs and the rest of LimbicPRO require a paid or
comped Pro subscription.
Before opening a pull request, run the Development checks below.
Local quality gates and what GitHub Actions runs on every PR and every push to main.
Shipping the site is still Vercel — CI does not deploy.
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm run test:unit # Node's test runner on src/**/*.test.ts
npm test # Playwright e2e in e2e/
Unit tests use Node's built-in runner (not Vitest). They currently cover CSS payload and route-sheet reachability guards plus a few library helpers.
npm test is Playwright against a real app and the local SQLite database — there is no
mocked backend. The test process loads .env the same way Next does, so helpers write to
the same DATABASE_URL as the server. Set PLAYWRIGHT_DATABASE_URL (for example
file:./e2e.db) to use a scratch file instead; Playwright migrates it and deletes
pw-*@example.com accounts at the start and end of the run. Locally it starts next dev
if nothing is already on :3000, or reuses that server. Copy .env.example
to .env and apply migrations first (see Getting started). The first
time you run e2e locally:
npx playwright install chromium
Auth/onboarding coverage includes a regression for the sign-in rate limiter (see
Password auth & reset emails below). The suite also walks
other critical journeys in e2e/; it is not a full product tour.
.github/workflows/ci.yml runs on every pull request and
every push to main (Node 22). Two jobs:
npm ci, then npm run typecheck, npm run lint, and
npm run test:unit.npm ci, copy .env.example to .env with a generated
SESSION_SECRET, apply migrations, install Chromium, then npm test. Under CI=true,
Playwright builds the app (npm run build) and serves that build instead of next dev.CI uses file SQLite (DATABASE_URL=file:./dev.db from .env.example) and stub/empty
secrets except the generated session key. It does not exercise live Stripe, Resend, or
other paid APIs.
Every push to the GitHub branch connected in Vercel
redeploys. The build command is
node scripts/apply-migrations.mjs && next build (npm run build): migrations against
the configured database, then next build. CI is the quality gate; Vercel is what ships.
None of this can be done from a coding assistant — each step needs your own account — but here's the exact path, start to finish.
1. Create a Turso database (free tier is plenty for this app):
curl -sSfL https://get.tur.so/install.sh | bash # installs the turso CLI
turso auth signup # or `turso auth login` if you have one
turso db create pt-news
turso db show pt-news --url # → libsql://pt-news-<your-org>.turso.io
turso db tokens create pt-news # → a long auth token
2. Put this project on GitHub (skip if it's already there):
gh repo create pt-news --private --source=. --push
# or, without gh: create an empty repo on github.com, then
# git remote add origin <your-repo-url> && git push -u origin main
3. Import it into Vercel: vercel.com/new → pick the GitHub repo → it auto-detects Next.js, no build settings to change.
4. Before the first deploy finishes, add these Environment Variables in the Vercel project's Settings → Environment Variables (Production, and Preview if you want preview deploys to work too):
| Name | Value |
|---|---|
DATABASE_URL | the libsql://... URL from step 1 |
TURSO_AUTH_TOKEN | the token from step 1 |
SESSION_SECRET | output of openssl rand -base64 32 |
ANTHROPIC_API_KEY | an API key from console.anthropic.com — powers the "Ask AI to search PubMed" feature (see below); the rest of the app works without it |
YOUTUBE_API_KEY | a free API key from console.cloud.google.com (enable the "YouTube Data API v3" on the project) — powers live-sourced Clips; without it, Clips falls back to only the curated static set |
PEXELS_API_KEY | a free API key from pexels.com/api — powers topic-matched stock-photo fallback images on Home feed cards that don't have their own real og:image; without it, those cards just render without an image |
GOOGLE_CLIENT_ID | an OAuth 2.0 Client ID (Web application) from console.cloud.google.com — powers "Continue with Google" on the sign-in screen; see below |
GOOGLE_CLIENT_SECRET | the matching client secret from the same OAuth client |
GOOGLE_HEALTH_CLIENT_ID / GOOGLE_HEALTH_CLIENT_SECRET | Google Health API OAuth client for "Connect Fitbit" on the Activity Log. Redirect URI https://limbic.center/auth/fitbit/callback. Without both, that row shows "Coming soon" |
STRAVA_CLIENT_ID / STRAVA_CLIENT_SECRET | Strava API app (strava.com/settings/api, callback domain limbic.center) for "Connect Strava". Without both, that row does not render |
UNPAYWALL_EMAIL | contact email Unpaywall requires on free-full-text lookups. Optional locally; set your own in production |
5. Redeploy (Vercel → Deployments → ⋯ → Redeploy) so the build picks up the new env
vars. The build command (node scripts/apply-migrations.mjs && next build) applies the
database schema to your Turso database automatically on every deploy — you don't need to
run migrations by hand.
That's it — you'll get a *.vercel.app URL. Every push to the connected branch redeploys
automatically after that. Production OAuth redirect URIs are hardcoded to
https://limbic.center, so the full Google, Fitbit, and Strava consent flows complete on
that domain.
vercel.json registers seven daily Vercel Cron hits, each a GET under
src/app/api/cron/ that requires Authorization: Bearer <CRON_SECRET>:
| Path | What it warms |
|---|---|
/api/cron/refresh-daily-insights | per-reader daily insights |
/api/cron/refresh-live-feeds | Google News, PubMed, APTA, and wellness aggregations |
/api/cron/migration-reminders | account-migration reminder email |
/api/cron/refresh-interest-profiles | LLM interest profiles used in feed ranking |
/api/cron/sync-fitness-trackers | connected Fitbit and Strava accounts |
/api/cron/refresh-unpaywall-cache | free-full-text links for live PubMed articles |
/api/cron/warm-evidence-breakdowns | evidence-library study breakdowns |
Unset CRON_SECRET and each route returns 503 rather than running unauthenticated.
The design's chat history shows the author explicitly asking for real sourcing ("pull information from credible sources... use yahoo finance to get the true price of USPH") instead of the prototype's static seed data. This build wires that up for real:
src/lib/pubmed.ts): sourced live from PubMed (NCBI E-utilities) — the
actual authoritative medical-literature database, not a news search — since research
cards on the home feed should be real published studies. No API key required.src/lib/news-live.ts): fetched live via
Google News RSS (no API key required), one query per category, then keyword-classified
into a specialty. A result only survives if it actually matches professional/medical-
sector language for its category — the home feed should only ever surface PubMed
research or confidently-classified medical-sector news, not general health/lifestyle
journalism, so anything that merely came back from a loosely-matching search is dropped
rather than kept under its query's category by default. General wellness content lives
only on the Health & Wellness page, sourced separately (fetchLiveWellness).src/lib/apta-news.ts): scraped live from
apta.org/news for the dedicated APTA News section — see the
callout below, this one's riskier than the other sources.src/lib/stock.ts): USPH's price/sparkline is fetched live from Stooq's CSV
export, falling back to Yahoo Finance's chart endpoint, in that order.src/lib/clips-live.ts): real PT/rehab YouTube Shorts fetched live via the
YouTube Data API v3 (requires YOUTUBE_API_KEY), merged with the hand-curated set in
src/lib/clips-static.ts. Same relevance-gating principle as the Google News sources — a
result has to actually mention PT/rehab language to survive, not just come back from a
topically-scoped search. Without an API key, Clips is just the curated static set.src/lib/articles-static.ts) — the same hand-authored articles and CE
events from the original prototype for news, and a real USPH daily-close series
(captured via a live market-data connector while building this, 2026-06-24 through
2026-07-24, last close $74.58) for the stock card, not an invented one.Every other live source above is a public API/RSS feed built for programmatic access. APTA News is different: it's an HTML scrape of apta.org's website, which isn't designed to be scraped. Two things point to this possibly not working even in production, not just in this sandbox:
x-deny-reason: host_not_allowed) — expected, and true of most hosts here.Because of that, src/lib/apta-news.ts's selectors are deliberately URL-pattern-based
(matching /news/YYYY/MM/DD/...-shaped links) rather than tied to CSS classes I've never
actually seen — I couldn't inspect the real markup while building this. It falls back to
APTA_NEWS_SEED (src/lib/apta-news-static.ts, fictional but written in APTA's real news
categories — advocacy wins, CSM, workforce reports) whenever the live scrape returns fewer
than 3 results, so the section still looks populated either way. Worth checking after your
first deploy whether the live scrape is actually getting through.
The Search screen has an "Ask AI to search PubMed" card: describe what you're looking for
in plain language (e.g. "blood-flow restriction training after ACL repair") and Claude
(src/lib/ai-pubmed-query.ts, model claude-opus-5) turns it into a proper PubMed query
using field tags and boolean operators, scoped to PT/rehab literature. The generated query
is shown above the results for transparency, and the search runs directly against PubMed
(searchPubmed in src/lib/pubmed.ts) — nothing is fabricated by the model. If the
Anthropic API call fails or ANTHROPIC_API_KEY isn't set, it falls back to using the raw
description as a literal PubMed search rather than breaking the page.
I could not verify the live-fetch path succeeds end-to-end in the sandbox this was
built in — its network policy blocks outbound HTTP to arbitrary hosts (confirmed via
curl and the fetch tool: even a plain request to Wikipedia returned 403), so every
request in that environment exercises the fallback path instead. The code is written
against the standard fetch() API with no sandbox-specific workarounds, so it should
work as soon as it runs somewhere with normal outbound internet access — but that claim
is based on reading the code, not a live observation, and is worth a quick check the
first time you deploy this somewhere with real egress.
The Clips feed's live-fetch path (src/lib/clips-live.ts) has the exact same "couldn't
verify against the real API" caveat as the section above — googleapis.com is also
outside this sandbox's allowlist, so the fallback (the curated static set alone) is all
that was ever actually exercised while building it. Once YOUTUBE_API_KEY is set,
worth confirming after deploy that the Clips feed is actually pulling in live results
(new clip titles beyond the original 9) rather than silently sitting on the static
fallback because of, say, a quota or key-restriction issue on the Google Cloud project.
The sign-in screen's "Continue with Google" button (src/components/SignInForm.tsx) is a
standard OAuth 2.0 authorization-code flow, hand-rolled against Google's endpoints rather
than a library — src/app/auth/google/route.ts redirects to Google's consent screen with a
random state value in a short-lived cookie; src/app/auth/google/callback/route.ts
checks that state on the way back, exchanges the authorization code for an ID token, and
verifies that token's signature against Google's own published keys (jose's
createRemoteJWKSet, already a dependency for this app's own session cookies — no new
package needed) before trusting any of its claims. The verified email is upserted into the
same User.email column the General (email) sign-in flow uses (signInWithGoogle in
src/lib/session.ts), so a reader who's used both ends up on one account either way. The
button itself only renders when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set.
The redirect URI is hardcoded to https://limbic.center/auth/google/callback (must exactly
match an Authorized redirect URI on the OAuth client in Google Cloud Console) rather than
derived from the incoming request, so the same registered round trip completes regardless
of which host actually served the initial request. That also means the full consent flow
can only be completed on the real deployed domain — I could verify the redirect to Google's
consent screen is correctly formed, but completing an actual sign-in requires a real
browser and a real Google account, which isn't something this sandbox could exercise
end-to-end either.
Sign-in used to accept any typed email with no password at all — the sign-in screen's own
copy said so outright ("Demo sign-in, any email works"). It's real password auth now:
signUpWithPassword/signInWithPassword in src/lib/session.ts hash with Node's built-in
scrypt (src/lib/password.ts — deliberately slow/memory-hard, unlike a fast general-
purpose hash, which is the whole point for password storage; no new dependency needed for
this half). Nothing ever stores or logs a plaintext password.
Every account created before this shipped has User.passwordHash = null. There's no
separate bulk migration — a legacy account's first sign-in attempt is met with "This
account hasn't set a password yet," pointing at the same "Forgot password?" flow an
ordinary reset uses. That flow is Resend-backed (src/lib/email.ts,
requestPasswordResetAction/resetPasswordAction in src/app/actions/auth.ts): a
single-use, SHA-256-hashed token (PasswordResetToken, src/lib/password-reset.ts)
expiring after an hour. Get a free key at resend.com, verify a sending domain there (Domains
→ Add Domain, then add the DNS records at your registrar — an unverified domain can only
send to your own Resend account email), and set RESEND_API_KEY/EMAIL_FROM. Without
RESEND_API_KEY, nothing fails — the reset link is logged to the server console instead of
emailed, which is also how this flow gets tested end-to-end in a sandbox with no real Resend
account.
The sign-in/forgot-password/reset-password flows are deliberately vague on failure
("Incorrect email or password" covers both a wrong password and no account at all; "forgot
password" always redirects to the same "check your email" state) so a failed attempt can't
be used to test which emails have accounts — the one exception is signup, which does say
"an account with that email already exists," the ~universal convention for that specific
screen. Sign-in and reset requests are both rate-limited (no Redis/Upstash — a plain per-email
fixed-window counter in the same SQLite/Turso database, SignInThrottle/
PasswordResetThrottle): sign-in locks an
email out for 15 minutes after 5 failed attempts (lib/sign-in-rate-limit.ts, cleared on a
successful sign-in), and reset requests are capped at 3 per hour per email regardless of
whether that address has an account (lib/password-reset-rate-limit.ts).
LimbicPRO ($10/mo) and LimbicStudent ($3/mo — see
src/app/(app)/pro/membership/page.tsx) are real, recurring Stripe subscriptions, not the
instant demo flip they used to be. LimbicStudent is a single plan (an earlier, separate
higher "Student PRO+ Boards" tier was retired in favor of one plan covering everything,
including Limbic Agent eligibility — see src/app/(app)/student/page.tsx). The same
Checkout and webhook path also bills LimbicWellness+ ($2/mo or $20/yr,
STRIPE_PRICE_WELLNESS_PLUS_MONTHLY / STRIPE_PRICE_WELLNESS_PLUS_YEARLY,
src/app/(app)/wellness/membership/page.tsx) and Clinic PRO ($100/mo,
STRIPE_PRICE_CLINIC, team membership in src/lib/clinic-pro.ts). A plan whose Price id
is unset disables that plan's purchase button (planHasPrice in src/lib/stripe.ts).
The flow:
app/actions/pro.ts subscribeToProAction/subscribeToStudentTierAction):
looks up or creates a Stripe Customer for the reader (User.stripeCustomerId, reused on
every later checkout/portal visit instead of minting a new one each time), then redirects
to a Stripe-hosted Checkout Session. isPro/studentTier are not set here — only the
webhook below sets them, once Stripe actually confirms payment.cancelProAction/cancelStudentTierAction) redirects to the
Stripe-hosted Customer Portal, where the reader manages their payment method or cancels
— Stripe's default portal cancellation is "at period end," matching the wording in
/terms.src/app/api/stripe/webhook/route.ts) is the single source of truth:
verifies the raw request body against STRIPE_WEBHOOK_SECRET before trusting anything in
it, then sets isPro/studentTier off customer.subscription.created/.updated
(active/trialing → on) and .deleted (→ off) events. Which internal plan a subscription
maps to travels as metadata.plan on the Subscription object itself (stamped at
checkout), not just the Checkout Session, since subscription-lifecycle events reference
the Subscription, not the session that created it.Setup, in the Stripe Dashboard:
price_...,
not the Product id): LimbicPRO ($10/mo) → STRIPE_PRICE_PRO, LimbicStudent
($3/mo) → STRIPE_PRICE_LIMBIC_STUDENT, LimbicWellness+ ($2/mo and $20/yr) →
STRIPE_PRICE_WELLNESS_PLUS_MONTHLY / STRIPE_PRICE_WELLNESS_PLUS_YEARLY, Clinic PRO
($100/mo) → STRIPE_PRICE_CLINIC.stripe.billingPortal.sessions .create fails until a configuration exists, even a default one.https://<your-domain>/api/stripe/webhook,
subscribed to at least customer.subscription.created, customer.subscription.updated,
and customer.subscription.deleted. Copy its signing secret into
STRIPE_WEBHOOK_SECRET.sk_test_... while testing, sk_live_... once you flip live)
into STRIPE_SECRET_KEY.Without STRIPE_SECRET_KEY set, every Upgrade/Manage-membership button on
/pro/membership stays disabled with a "Payments aren't set up yet" notice —
stripeEnabled() (src/lib/stripe.ts) gates all of it, same graceful-degradation pattern
as YOUTUBE_API_KEY/GOOGLE_CLIENT_ID elsewhere in this app, except there's no silent
demo fallback anymore: showing a fake "purchase" as if it charged a real card would be
actively misleading now that this is meant to be real billing.
Same sandbox caveat as the other live integrations above: this sandbox's network
policy blocks outbound requests to api.stripe.com, so none of this could be exercised
against a real Stripe account while building it — no .env value was set here, tsc/
eslint/next build all pass, and the webhook route's signature verification and event
handling were reasoned through against Stripe's documented behavior rather than observed
live. Worth a real end-to-end test (a real test-mode checkout, confirming isPro flips,
canceling via the portal, confirming it un-flips at period end) the first time this runs
somewhere with real egress.
Admin is split in two, because "can work the license queue" and "can delete any account" should not be the same grant.
FOUNDING_FUNDERS_ADMIN_EMAILS (matched against either a
General sign-in email or a PT license sign-in's email, case-insensitively). An owner holds
every admin area, gets every paid tier and student-only area overlaid onto their account
(see lib/session.ts getCurrentUser()), and is the only kind of account that can appoint
or remove a co-admin. Leave the variable unset and there are no owners at all — every admin
surface stays closed and nobody can be appointed./admin/accounts. The areas are one per admin screen — License Queue,
Suggestions, Copyright Notices, Appraisals, Boards Question Tagging, Programs, Movement Lab
Requests, Connexion, Founding Funders — listed in
src/lib/admin-areas.ts with a description of what each one
opens up. A co-admin's sidebar lists exactly the screens they hold, and any other
/admin/* URL redirects them home./admin/accounts is owner-only and has no area. It carries the whole reader list —
every email, sign-in method and billing state — plus account deletion, paid-tier comps, and
the co-admin controls themselves. A co-admin never sees it, whatever else they hold, and
there is no grant that would open it. Don't add one back to ADMIN_AREAS.foundingFunders is the payment job, not a near-owner grant. A co-admin with that
area sees the Founding Funder payment roster and the manual claim form on
/founding-funders. They do not see the all-users registered roster (name, email,
license, isPro) and claimFoundingSpotAction does not write User.isPro for them.
Owners still see the roster, and an owner claiming a spot for someone else still flips
isPro so Lifetime Access is immediate. Nobody can use the claim action to grant
themselves Pro. Comp a reader from /admin/accounts (grantAccessAction) if a co-admin
recorded a spot that still needs the paid tier.Three properties are worth knowing when changing this:
hasAdminArea()
(src/lib/admin.ts). A Server Action is a callable endpoint, so the
page's redirect is never the enforcement — adding an admin action means adding its check.grantAdminAreaAction/
revokeAdminAreaAction gate on the env allowlist, which is also why the page they live on
is owner-only: admin areas are data an admin could otherwise grant themselves, so the right
to edit that data stays with an identity the database can't forge.User.adminAreas
is deliberately absent from the paid-tier overlay: delegating the license queue does not
hand anyone LimbicPro. Comp a tier explicitly with the Granted Access chips if that is
what you meant.Revoking takes effect on the co-admin's next request — nothing about their access is baked into their session cookie.
The one-time $40 "Claim a Spot" purchase on /founding-funders (see
src/lib/founding-funders-config.ts's FOUNDING_FUNDERS_OPEN flag,
components/founding-funders/ClaimSpotButton.tsx) is a real, single-charge Stripe
Checkout Session — mode: "payment", not a subscription — kept separate from the
recurring-billing flow above because it's a different Checkout mode with its own Price id
and its own webhook event.
app/actions/founding-funders.ts createFoundingFunderCheckout): creates
a FoundingFunder row with paymentStatus: "pending" first (so the spot counts against
the 25-spot cap immediately, before payment even starts), then a Checkout Session with
metadata.foundingFunderId pointing back at that row. Unlike subscribeToProAction
above, this doesn't redirect() itself — it returns the Session URL so
ClaimSpotButton.tsx (a Client Component) can show its own "Processing..." state before
navigating there.checkout.session.completed (fired by every completed
Checkout Session, subscription or one-time alike) — purely additive: a subscription
checkout's Session has no metadata.foundingFunderId, so it's a no-op for
LimbicPro/Student/Wellness+ and only ever flips a Founding Funder row to
paymentStatus: "confirmed".?success=true&session_id=...) re-checks the session
directly and confirms right away if the webhook hasn't landed yet
(confirmFoundingFunderPaymentIfNeeded). The cancel page
(?canceled=true&session_id=...) deletes the pending row for an abandoned checkout
(cleanupCanceledFoundingFunderCheckout), so it doesn't sit around counting against the
cap forever.components/founding-funders/FoundingFundersRoster.tsx (visible to
anyone with the foundingFunders area, at the bottom of the page) lists every pending/
confirmed claim with a manual "Confirm Payment" button for when a webhook never fires, and
"Remove" to delete a stale claim and reopen the spot. The all-users registered roster
(RegisteredUsersPanel) and the isPro write on claimFoundingSpotAction stay
owner-only — see the Admin access section above.Setup, in the Stripe Dashboard, before flipping FOUNDING_FUNDERS_OPEN to true:
price_..., not the
Product id) into STRIPE_FOUNDING_FUNDER_PRICE_ID.https://<your-domain>/api/stripe/webhook) — just add checkout.session.completed to
its subscribed events (alongside the three customer.subscription.* ones). One endpoint
handles both flows; no second webhook needed.STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET are shared with the subscriptions flow above
— nothing extra to set there.Without STRIPE_FOUNDING_FUNDER_PRICE_ID set (even with STRIPE_SECRET_KEY configured),
createFoundingFunderCheckout returns "Payments aren't set up yet" rather than starting a
checkout with no real price behind it. claimFoundingSpotAction is the manual counterpart
for a payment confirmed outside Checkout: anyone with the foundingFunders area can record
the spot, and only an owner claiming someone else writes User.isPro.
The "Latest news" card in the Home sidebar (src/components/RevolvingNews.tsx) rotates
through the most recent Guidelines/Industry & Policy/Equipment articles — general news
from news outlets, deliberately excluding Research (PubMed is an academic-literature
database, not a news outlet) and CE & Events (a curated calendar, not news). See
NEWS_TICKER_TYPES in src/app/(app)/page.tsx.
Each card shows the real image the article itself uses — its og:image, fetched
server-side (src/lib/og-image.ts) from the article's own page, the same image the
publisher uses for its own social-media previews. If no image can be found (or an article
has no sourceUrl at all, true of seed/fallback content), a topic-matched stock photo
fills the gap instead — see "Topic-image fallback" below. This runs for the ~6 articles
shown in the ticker plus the top-ranked ~16 articles on the main Home feed (see
FEED_IMAGE_LIMIT in src/app/(app)/page.tsx), not the entire pool, and fails silently
per-article so one slow or blocked page doesn't take down the rest.
Most seed and AOPT clinical-practice-guideline content has no og:image to find at all —
seed articles have no sourceUrl, and a guideline PDF has no <meta> tags of any kind. For
those, src/lib/topic-image.ts searches a real, freely-licensed stock photo via the Pexels
API (src/lib/pexels.ts), matched to a specific anatomical/topical term recognized in the
article's title (e.g. "knee", "rotator cuff") when there is one, falling back to the
article's specialty (e.g. "Orthopedic physical therapy rehabilitation") otherwise — so every
article that reaches this fallback still resolves to something relevant, not a generic
placeholder. Same graceful-degradation pattern as YOUTUBE_API_KEY: without
PEXELS_API_KEY set, those cards just render without an image.
Same sandbox caveat as Clips above — api.pexels.com is outside this sandbox's allowlist,
so this could only be verified by inspecting the built query strings and the graceful
no-key/no-result fallback paths, not by confirming a real photo actually comes back. Worth
spot-checking after deploy that a few seed/guideline cards on Home are picking up sensible,
on-topic photos rather than silently sitting on "no image" because of a quota or key issue.
The Under Review section (src/lib/retraction-watch-data.ts) is a snapshot of real
retractions, corrections, and expressions of concern from PT/rehab journals, pulled from
the Crossref/Retraction Watch database
— the authoritative public list of retracted papers across all of science. Each entry
links out to the actual retraction notice (or the original paper, when no notice URL is
recorded) so clinicians can read the primary source rather than a summary of it.
It's a snapshot, not a live fetch: the source CSV is the entire Retraction Watch
database (~65MB, every field, every discipline), which is too large to download inside a
single serverless request, and Crossref only updates it daily anyway. Instead,
scripts/fetch-retraction-watch.mjs downloads it, filters to journals PTs actually read
(by journal name — the dataset's own "Rehabilitation/Therapy" subject tag turned out to
mean general medical therapy, not the physical therapy profession, so it's not a
reliable filter here), and writes the result straight into
src/lib/retraction-watch-data.ts. Re-run it (npm run fetch:retraction-watch) whenever
you want a fresher snapshot — the current one is pinned to a specific GitLab commit
(recorded in the generated file's header) rather than "latest," so re-runs are
reproducible. MeSH headings used for search are the same kind of baked snapshot:
npm run fetch:mesh-terms regenerates src/lib/mesh-terms.ts from NLM's annual tree file.
Unlike the news/stock sources above, gitlab.com's raw-file endpoint was reachable directly from the sandbox this was built in — this doesn't imply the other live sources (Google News, PubMed, Yahoo/Stooq, the Anthropic API) are reachable too, since sandbox network policy is host-specific; it's just how this one snapshot could actually be verified against real data rather than reasoned about from code alone.
A couple of pieces intentionally stayed static, because "live" doesn't cleanly apply to them:
The exported .dc.html had three "tweak" props for the design tool's own preview
(feedLayout, heroStory, density) — these were author-facing design-iteration knobs,
not a settings screen the end user was ever meant to see, so this build fixes them at
their last-configured defaults (cards layout, hero story on, compact density) instead
of exposing a UI nobody asked for.
Responsive layout (sidebar+desktop vs. bottom-nav+mobile) is done with CSS media queries
at the same 800px breakpoint the prototype used in JS, rather than a window.innerWidth
check — avoids any server/client hydration mismatch.
RESEND_API_KEY / EMAIL_FROM |
| password-reset (and other) email. See Password auth & reset emails. Without the key, reset links are logged to the server console |
FOUNDING_FUNDERS_ADMIN_EMAILS | comma-separated owner emails. See Admin access |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | Stripe billing. See Stripe subscriptions |
STRIPE_PRICE_PRO / STRIPE_PRICE_LIMBIC_STUDENT | monthly Price ids for LimbicPRO ($10) and LimbicStudent ($3) |
STRIPE_PRICE_WELLNESS_PLUS_MONTHLY / STRIPE_PRICE_WELLNESS_PLUS_YEARLY | LimbicWellness+ ($2/mo and $20/yr) |
STRIPE_PRICE_CLINIC | Clinic PRO ($100/mo) |
STRIPE_FOUNDING_FUNDER_PRICE_ID | one-time $40 Founding Funder Price id. See Founding Funders payments |
CRON_SECRET | bearer token Vercel Cron sends to src/app/api/cron/*. Without it those routes stay disabled (503). Generate with openssl rand -base64 32 |