Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"start": "bun src/main.js"
},
"dependencies": {
"@profullstack/affiliates": "0.3.0",
"@profullstack/profile": "0.1.6",
"@profullstack/redeem-credits": "0.2.0",
"@profullstack/x402-gateway": "^0.13.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tleehealth/db": "workspace:*",
"@tleehealth/payments": "workspace:*",
"disposable-email-domains": "1.0.62",
"hono": "^4.13.14"
}
}
92 changes: 90 additions & 2 deletions apps/web/public/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ async function render() {
await loadMe();
if (path === '/portal/records') return renderRecords(q);
if (path.startsWith('/portal')) return renderPortal();
if (path === '/app/invites' && !state.orgId) {
const v = await viewInvites(q);
root.innerHTML = `<div class="center"><div class="auth" style="max-width:640px"><a class="brand" href="/app" style="padding:0">${logo} tleehealth</a>${v.html}<p><a class="btn" href="/app">Set up your practice</a></p></div></div>`;
return v.bind();
}
if (!state.orgId) return state.me.patient_of.length && path !== '/app/new' ? go('/portal') : renderNewPractice();
if (path === '/app/new') return renderNewPractice();

Expand All @@ -204,6 +209,7 @@ async function render() {
else if (path === '/app/locations') view = viewLocations();
else if (path === '/app/billing') view = await viewBilling(q);
else if (path === '/app/settings') view = await viewSettings();
else if (path === '/app/invites') view = await viewInvites(q);
else view = await viewToday(q);
root.innerHTML = shell(path, view.html);
bindShell();
Expand Down Expand Up @@ -238,6 +244,7 @@ function shell(path, main) {
${navItem('/app/team', 'Team')}
${navItem('/app/locations', 'Locations')}
${is(ADMIN) ? navItem('/app/billing', 'Billing', o.billing.active ? '' : '<span class="count">unpaid</span>') : ''}
${navItem('/app/invites', 'Invites')}
${navItem('/app/settings', 'Settings')}
${state.me.patient_of.length ? navItem('/portal', 'My health') : ''}
${navItem('/portal/records', 'My records')}
Expand Down Expand Up @@ -309,7 +316,8 @@ function renderSignin(q, signup = false) {
e.preventDefault();
const data = formData(e.target);
try {
await post('/auth/link', signup ? { ...data, mode: 'signup' } : { email: data.email });
const invite = q.get('invite') ?? undefined;
await post('/auth/link', signup ? { ...data, mode: 'signup', invite } : { email: data.email, invite });
e.target.innerHTML = `<div class="card"><div class="card-b"><h2>Check your email</h2><p class="muted">We sent a sign-in link to <b>${esc(data.email)}</b>. It works once and expires in 20 minutes.</p></div></div>`;
} catch (err) {
showFieldErrors(e.target, err.errors);
Expand Down Expand Up @@ -1192,7 +1200,7 @@ function viewLocations() {
/* --------------------------------------------------------------- billing -- */

async function viewBilling(q) {
const { billing: b, payments } = await get(`/orgs/${state.orgId}/billing`);
const [{ billing: b, payments }, { balance }] = await Promise.all([get(`/orgs/${state.orgId}/billing`), get('/credits')]);
const html = `
<div class="head"><h1>Billing</h1></div>
${q.get('paid') ? '<div class="card"><div class="card-b">Thanks. Your month is added as soon as CoinPay confirms the payment, usually within a few minutes.</div></div>' : ''}
Expand All @@ -1207,6 +1215,12 @@ async function viewBilling(q) {
</dl>
${b.payments_enabled ? `<div><button class="primary" id="pay">Pay ${money(b.monthly_cents)} for one month</button></div><p class="dim small">Paid through CoinPay in any major coin or PayPal. Each payment adds one month.</p>` : '<p class="muted">Online payment is not switched on yet. Your practice keeps working; we will email the owner before it is needed.</p>'}
</div></section>
<section class="card wide" id="redeem"><div class="card-h"><h2>Have a code?</h2><span class="dim small">balance ${balance} day${balance === 1 ? '' : 's'}</span></div>
<div class="card-b stack">
<form id="redeem-form" class="row"><label class="f" style="flex:1">Credit code<input name="code" class="mono" placeholder="CR-XXXX-XXXX-XXXX-XXXX" required autocomplete="off"></label><button type="submit" style="align-self:flex-end">Redeem for ${esc(state.org.org.name)}</button></form>
${balance > 0 ? `<form id="apply-form" class="row"><label class="f" style="flex:1">Days from your balance<input name="days" type="number" min="1" max="${balance}" value="${balance}"></label><button type="submit" style="align-self:flex-end">Add to ${esc(state.org.org.name)}</button></form>` : ''}
<p class="dim small">Credit codes and invites give days of the paid plan. Redeeming here adds them to this practice; days from invites wait in your balance until you add them. <a href="/app/invites">Invite someone</a>.</p>
</div></section>
<section class="card narrow"><div class="card-h"><h2>Payments</h2></div>
${payments.length ? `<ul class="list">${payments.map((p) => `<li><span>${money(p.amount_cents)}<div class="sub">${fmtDate(p.created_at)}</div></span><span class="badge">${esc(p.status)}</span></li>`).join('')}</ul>` : '<div class="empty">No payments yet.</div>'}
</section>
Expand All @@ -1222,6 +1236,80 @@ async function viewBilling(q) {
toast(err.message, true);
}
});
document.getElementById('redeem-form').onsubmit = async (e) => {
e.preventDefault();
try {
const r = await post('/credits/redeem', { ...formData(e.target), org_id: state.orgId });
toast(r.applied?.ok ? `Added ${r.applied.days} days. Paid through ${fmtDate(r.applied.paid_through)}.` : `Added ${r.grant.amount} days to your balance`);
await loadMe();
render();
} catch (err) {
toast(err.message, true);
}
};
const apply = document.getElementById('apply-form');
if (apply) apply.onsubmit = async (e) => {
e.preventDefault();
try {
const r = await post(`/orgs/${state.orgId}/billing/apply-credits`, { days: Number(formData(e.target).days) });
toast(`Added ${r.days} days. Paid through ${fmtDate(r.paid_through)}.`);
await loadMe();
render();
} catch (err) {
toast(err.message, true);
}
};
},
};
}

/* --------------------------------------------------------------- invites -- */

async function viewInvites(q) {
const { quota, invites, can_accept, bonus_days } = await get('/invites');
const state_ = (i) =>
i.revokedAt ? 'revoked' : i.usedAt ? `accepted ${fmtDate(i.usedAt)}` : i.expiresAt && i.expiresAt < Date.now() ? 'expired' : `open until ${fmtDate(i.expiresAt)}`;
const html = `
<div class="head"><h1>Invites</h1></div>
${q.get('joined') ? `<div class="card"><div class="card-b">Welcome. Your invite counted: ${bonus_days} days of the paid plan are in your balance; add them to your practice under Billing.</div></div>` : ''}
<div class="cols">
<section class="card wide"><div class="card-h"><h2>Invite someone</h2><span class="dim small">${quota.left} of ${quota.total} left</span></div>
<div class="card-b stack">
<p class="muted">When someone new signs up with your invite, you both get ${bonus_days} days of the paid plan. Each invite works once and expires after 30 days.</p>
<form id="inv-form" class="row"><label class="f" style="flex:1">Who is it for? (optional)<input name="note" maxlength="120"></label><button type="submit" style="align-self:flex-end" ${quota.left < 1 ? 'disabled' : ''}>Create invite</button></form>
<div id="inv-link"></div>
</div>
${invites.length ? `<ul class="list">${invites.map((i) => `<li><span>${esc(i.note || 'Invite')}<div class="sub">${fmtDate(i.createdAt)}</div></span><span class="badge">${esc(state_(i))}</span></li>`).join('')}</ul>` : ''}
</section>
${can_accept ? `<section class="card narrow"><div class="card-h"><h2>Got an invite?</h2></div><div class="card-b stack">
<p class="dim small">New accounts can accept one invite.</p>
<form id="acc-form" class="stack"><label class="f">Invite code<input name="code" class="mono" value="${esc(q.get('code') ?? '')}" placeholder="INV-XXXX-XXXX-XXXX" required></label><button type="submit">Accept</button></form>
</div></section>` : ''}
</div>`;
return {
html,
bind() {
document.getElementById('inv-form').onsubmit = async (e) => {
e.preventDefault();
try {
const { invite } = await post('/invites', formData(e.target));
document.getElementById('inv-link').innerHTML = `<p class="dim small">Send this link. It is shown only once.</p><p class="mono small" style="word-break:break-all">${esc(invite.url)}</p>`;
e.target.reset();
} catch (err) {
toast(err.message, true);
}
};
const acc = document.getElementById('acc-form');
if (acc) acc.onsubmit = async (e) => {
e.preventDefault();
try {
const r = await post('/invites/accept', formData(e.target));
toast(`Invite accepted: ${r.days} days added to your balance`);
go('/app/invites');
} catch (err) {
toast(err.message, true);
}
};
},
};
}
Expand Down
40 changes: 40 additions & 0 deletions apps/web/scripts/mint-credit-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bun
// Mint a tleehealth credit code (staff only; there is no minting API).
//
// docker exec <container> bun apps/web/scripts/mint-credit-code.js --days 30 [--max 1] [--expires-days 90] [--label "for Dr Lin"]
// ... --list every code (hashes only; codes are never stored)
// ... --revoke crd_... revoke one
//
// The code is printed once. It grants pro_days, applied to a practice's paid period.
import { close } from '@tleehealth/db';
import { getCredits, UNIT } from '../src/credits.js';

const args = process.argv.slice(2);
const opt = (name) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : undefined;
};
const c = getCredits();
try {
if (args.includes('--list')) {
for (const k of await c.list())
console.log(`${k.id}\t${k.grant.amount} ${k.grant.unit}\t${k.redemptions}/${k.maxRedemptions}\t${k.revokedAt ? 'revoked' : 'live'}\t${k.label}`);
} else if (opt('revoke')) {
await c.revoke(opt('revoke'));
console.log('revoked');
} else {
const days = Number(opt('days'));
if (!Number.isInteger(days) || days < 1) throw new Error('--days N is required');
const expires = Number(opt('expires-days') ?? 0);
const m = await c.mint({
grant: { unit: UNIT, amount: days },
maxRedemptions: Number(opt('max') ?? 1),
expiresAt: expires ? Date.now() + expires * 86400_000 : null,
label: opt('label') ?? '',
createdBy: 'staff-script',
});
console.log(JSON.stringify({ code: m.code, id: m.id, days, max: m.maxRedemptions, expires_at: m.expiresAt }));
}
} finally {
await close();
}
71 changes: 70 additions & 1 deletion apps/web/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { sendAddedToOrg, sendLoginLink } from './mail.js';
import * as calls from './calls.js';
import * as nav from './navigation.js';
import * as records from './records.js';
import * as credits from './credits.js';

/**
* /api/v1: the only thing any client talks to. The browser app, the CLI, the TUI
Expand Down Expand Up @@ -129,7 +130,10 @@ api.post('/auth/link', async (c) => {
}
// Same answer whether or not the address has an account.
try {
await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile }) });
// An invite (from /i/<code>, or typed) rides on the emailed link, so opening
// it on another device still counts. Only a link that creates the account uses it.
const invite = credits.cleanInvite(b.invite) ?? credits.cleanInvite(getCookie(c, credits.INVITE_COOKIE));
await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile, invite }) });
} catch (err) {
console.error(`[auth] could not send link: ${err?.message ?? err}`);
}
Expand Down Expand Up @@ -1421,6 +1425,71 @@ api.post('/orgs/:org/appointments/:id/call', async (c) => {
return c.json({ call: after });
});

/* ------------------------------------------------- credit codes + invites -- */
// Codes and invites grant pro_days into the user's balance; an owner or org
// manager applies them to a practice (org_billing.paid_through). Minting is
// staff-only and done with scripts/mint-credit-code.js, never over the API.

api.get('/credits', async (c) => {
const user = await requireUser(c);
const g = credits.getCredits();
const [balance, history] = await Promise.all([g.balance(user.id, credits.UNIT), g.history(user.id, credits.UNIT)]);
return c.json({ unit: credits.UNIT, balance, history });
});

api.post('/credits/redeem', async (c) => {
const user = await requireUser(c);
const b = await body(c);
const code = str(b.code, 64);
if (!code) return c.json({ error: 'enter a code' }, 400);
// Check the practice before the code is spent, so a bad org never burns it.
let me = null;
if (b.org_id) ({ me } = await member(c, b.org_id, ADMIN));
const r = await credits.getCredits().redeem(code, user.id);
if (!r.ok) return c.json({ error: credits.REDEEM_MESSAGES[r.reason] ?? 'that code did not work', reason: r.reason }, 400);
let applied = null;
if (me && r.grant.unit === credits.UNIT) {
applied = await credits.applyDays(user.id, me.org_id, r.grant.amount);
if (applied.ok) await audit(me.org_id, user.id, 'billing.credit_apply');
}
return c.json({ ok: true, grant: r.grant, applied, balance: await credits.getCredits().balance(user.id, credits.UNIT) });
});

api.post('/orgs/:org/billing/apply-credits', async (c) => {
const { me, user } = await member(c, c.req.param('org'), ADMIN);
const days = Number((await body(c)).days);
const r = await credits.applyDays(user.id, me.org_id, days);
if (!r.ok) return c.json({ error: r.error, balance: r.balance }, 400);
await audit(me.org_id, user.id, 'billing.credit_apply');
return c.json(r);
});

api.get('/invites', async (c) => {
const user = await requireUser(c);
const aff = credits.getAffiliates();
const [quota, invites, invited_by] = await Promise.all([aff.quota(user.id), aff.invites(user.id), aff.invitedBy(user.id)]);
const new_account = Date.now() - new Date(user.created_at).getTime() <= credits.INVITEE_MAX_AGE_DAYS * 86400_000;
return c.json({ quota, invites, can_accept: !invited_by && new_account, bonus_days: credits.INVITE_BONUS_DAYS });
});

api.post('/invites', async (c) => {
const user = await requireUser(c);
const note = str((await body(c)).note, 120) ?? '';
const aff = credits.getAffiliates();
if ((await aff.quota(user.id)).left < 1) return c.json({ error: 'no invites left' }, 400);
const invite = await aff.createInvite(user.id, note);
return c.json({ invite: { ...invite, url: `${config.siteUrl}/i/${invite.code}` }, quota: await aff.quota(user.id) }, 201);
});

api.post('/invites/accept', async (c) => {
const user = await requireUser(c);
const code = credits.cleanInvite((await body(c)).code);
if (!code) return c.json({ error: 'enter an invite code' }, 400);
const r = await credits.acceptInvite(user.id, code);
if (!r.ok) return c.json({ error: credits.ACCEPT_MESSAGES[r.reason] ?? r.reason, reason: r.reason }, 400);
return c.json({ ok: true, days: r.inviteeDays });
});

/* --------------------------------------------------------------- billing -- */

async function billing(orgId) {
Expand Down
40 changes: 39 additions & 1 deletion apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { createCrawlGate } from './crawl-gate.js';
import { api, coinpayWebhook, saveSignup } from './api.js';
import { withPitFooter } from './pit-footer.js';
import * as auth from './auth.js';
import { config } from './config.js';
import * as credits from './credits.js';
import * as calls from './calls.js';
import * as records from './records.js';
import * as telnyx from './telnyx.js';
Expand Down Expand Up @@ -88,9 +90,43 @@ app.get('/auth/magic', async (c) => {
const s = token ? await auth.consumeLoginLink(token, { userAgent: c.req.header('user-agent') }) : null;
if (!s) return c.redirect('/signin?error=expired', 302);
await saveSignup(s.user, s.profile);
// The link proved the mailbox; a link that just created the account takes its invite.
let to = '/app';
if (s.created && s.invite) {
try {
const r = await credits.acceptInvite(s.user.id, s.invite);
console.log(`[invite] ${s.user.id} ${r.ok ? 'accepted' : r.reason}`);
if (r.ok) to = '/app/invites?joined=1';
} catch (err) {
console.error(`[invite] accept failed: ${err?.message ?? err}`);
}
}
c.header('set-cookie', auth.sessionCookie(s.sessionId));
return c.redirect('/app', 302);
if (s.invite) c.header('set-cookie', `${credits.INVITE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`, { append: true });
return c.redirect(to, 302);
});

// An invite link: keep the code (cookie + the signup link) and send them to sign up.
app.get('/i/:code', async (c) => {
const code = credits.cleanInvite(c.req.param('code'));
if (!code) return c.redirect('/signup', 302);
c.header(
'set-cookie',
`${credits.INVITE_COOKIE}=${code}; Path=/; Max-Age=${30 * 86400}; HttpOnly; SameSite=Lax${config.isProd ? '; Secure' : ''}`,
);
const esc = (s) => String(s).replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
return c.html(
await withPitFooter(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>You're invited to tleehealth</title><meta name="robots" content="noindex"><link rel="stylesheet" href="/assets/app.css"><link rel="icon" href="/icon.svg"></head>
<body><div class="center"><div class="auth"><h1>You're invited to tleehealth</h1>
<p class="muted">Scheduling, prescriptions, labs and AI follow-up calls for your practice. Join with this invite and you both get ${credits.INVITE_BONUS_DAYS} days of the paid plan, on top of the 14-day trial.</p>
<p class="mono small">${esc(code)}</p>
<p><a class="btn primary" href="/signup?invite=${encodeURIComponent(code)}">Create your account</a></p>
<p class="dim small">Already have an account? <a href="/app/invites?code=${encodeURIComponent(code)}">Sign in</a>; invites count for accounts in their first ${credits.INVITEE_MAX_AGE_DAYS} days.</p>
</div></div></body></html>`),
);
});
app.get('/invites', (c) => c.redirect('/app/invites', 302));

// The provider (MyChart or any SMART on FHIR portal) sends the patient back here
// after they sign in there and allow access. The import starts right away.
Expand Down Expand Up @@ -224,6 +260,8 @@ Authenticate with a session cookie or \`Authorization: Bearer th_live_...\` (cre
- GET /orgs/:org/patients/:id/records what that patient shared with the practice (audit-logged)
- GET|PUT /orgs/:org/call-settings AI calls: on/off, hours before/after, calling window, attempts
- POST /orgs/:org/appointments/:id/call call the patient now ({"kind":"reminder"|"followup"})
- GET /credits, POST /credits/redeem {code, org_id?}, POST /orgs/:org/billing/apply-credits {days} credit codes: days of a practice's paid plan
- GET|POST /invites, POST /invites/accept {code} invites: 3 per account, both sides get 7 days; landing page /i/<code>
- GET /orgs/:org/calls/:id outcome, summary and transcript; POST .../resolve when handled
- GET|PUT /orgs/:org/patients/:id/care-plan, POST .../care-plan/items, PATCH /orgs/:org/care-plan-items/:id
goals and tasks with owners and due dates; shared plans show in the portal
Expand Down
Loading
Loading