diff --git a/apps/web/package.json b/apps/web/package.json index 394e59f..13c29dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/public/app/app.js b/apps/web/public/app/app.js index cda7556..26b29bc 100644 --- a/apps/web/public/app/app.js +++ b/apps/web/public/app/app.js @@ -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 = `
${logo} tleehealth${v.html}

Set up your practice

`; + return v.bind(); + } if (!state.orgId) return state.me.patient_of.length && path !== '/app/new' ? go('/portal') : renderNewPractice(); if (path === '/app/new') return renderNewPractice(); @@ -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(); @@ -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 ? '' : 'unpaid') : ''} + ${navItem('/app/invites', 'Invites')} ${navItem('/app/settings', 'Settings')} ${state.me.patient_of.length ? navItem('/portal', 'My health') : ''} ${navItem('/portal/records', 'My records')} @@ -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 = `

Check your email

We sent a sign-in link to ${esc(data.email)}. It works once and expires in 20 minutes.

`; } catch (err) { showFieldErrors(e.target, err.errors); @@ -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 = `

Billing

${q.get('paid') ? '
Thanks. Your month is added as soon as CoinPay confirms the payment, usually within a few minutes.
' : ''} @@ -1207,6 +1215,12 @@ async function viewBilling(q) { ${b.payments_enabled ? `

Paid through CoinPay in any major coin or PayPal. Each payment adds one month.

` : '

Online payment is not switched on yet. Your practice keeps working; we will email the owner before it is needed.

'} +

Have a code?

balance ${balance} day${balance === 1 ? '' : 's'}
+
+
+ ${balance > 0 ? `
` : ''} +

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. Invite someone.

+

Payments

${payments.length ? `` : '
No payments yet.
'}
@@ -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 = ` +

Invites

+ ${q.get('joined') ? `
Welcome. Your invite counted: ${bonus_days} days of the paid plan are in your balance; add them to your practice under Billing.
` : ''} +
+

Invite someone

${quota.left} of ${quota.total} left
+
+

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.

+
+ +
+ ${invites.length ? `` : ''} +
+ ${can_accept ? `

Got an invite?

+

New accounts can accept one invite.

+
+
` : ''} +
`; + 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 = `

Send this link. It is shown only once.

${esc(invite.url)}

`; + 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); + } + }; }, }; } diff --git a/apps/web/scripts/mint-credit-code.js b/apps/web/scripts/mint-credit-code.js new file mode 100644 index 0000000..02e1844 --- /dev/null +++ b/apps/web/scripts/mint-credit-code.js @@ -0,0 +1,40 @@ +#!/usr/bin/env bun +// Mint a tleehealth credit code (staff only; there is no minting API). +// +// docker exec 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(); +} diff --git a/apps/web/src/api.js b/apps/web/src/api.js index f90a819..8f10496 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -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 @@ -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/, 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}`); } @@ -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) { diff --git a/apps/web/src/app.js b/apps/web/src/app.js index ce1b046..79ad160 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -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'; @@ -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(` +You're invited to tleehealth +

You're invited to tleehealth

+

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.

+

${esc(code)}

+

Create your account

+

Already have an account? Sign in; invites count for accounts in their first ${credits.INVITEE_MAX_AGE_DAYS} days.

+
`), + ); }); +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. @@ -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/ - 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 diff --git a/apps/web/src/auth.js b/apps/web/src/auth.js index 623ffa2..b44547e 100644 --- a/apps/web/src/auth.js +++ b/apps/web/src/auth.js @@ -36,25 +36,28 @@ export const expectedOrigins = () => { * the token because the account row does not exist until the link is spent. * Bound as text and cast, so the driver never JSON-encodes the string a second time. */ -export async function createLoginLink(email, { profile = null } = {}) { +export async function createLoginLink(email, { profile = null, invite = null } = {}) { const token = randomBytes(32).toString('base64url'); await db()` - insert into login_tokens (token_hash, email, expires_at, profile) + insert into login_tokens (token_hash, email, expires_at, profile, invite) values (${sha(token)}, ${email.trim().toLowerCase()}, ${new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000)}, - ${profile ? JSON.stringify(profile) : null}::text::jsonb)`; + ${profile ? JSON.stringify(profile) : null}::text::jsonb, ${invite})`; return `${config.siteUrl}/auth/magic?t=${token}`; } -/** Spend a link. An address nobody has used before gets an account. */ +/** + * Spend a link. An address nobody has used before gets an account; `created` + * says so, and `invite` is the invite the signup carried (accepted only then). + */ export async function consumeLoginLink(token, { userAgent } = {}) { const [row] = await db()` update login_tokens set used_at = now() where token_hash = ${sha(token)} and used_at is null and expires_at > now() - returning email`; + returning email, invite`; if (!row) return null; const profile = await takeSignupProfile(sha(token)); - const user = await findOrCreateUser(row.email); - return { user, sessionId: await startSession(user.id, userAgent), profile }; + const { created, ...user } = await findOrCreateUser(row.email); + return { user, created: Boolean(created), invite: row.invite ?? null, sessionId: await startSession(user.id, userAgent), profile }; } /** @@ -81,7 +84,7 @@ export async function findOrCreateUser(email) { const [user] = await sql` insert into users (email) values (${e}) on conflict (lower(email)) do update set email = users.email - returning *`; + returning *, (xmax = 0) as created`; await linkPeople(user); return user; } diff --git a/apps/web/src/crawl-gate.js b/apps/web/src/crawl-gate.js index c20ca67..7c317ae 100644 --- a/apps/web/src/crawl-gate.js +++ b/apps/web/src/crawl-gate.js @@ -19,7 +19,7 @@ export function createCrawlGate(env = process.env) { coinpay: { apiKey }, payTo, contact: 'mailto:anthony@profullstack.com', - openPaths: ['/healthz', '/install.sh', '/llms.txt', '/api/', '/webhooks/'], + openPaths: ['/healthz', '/install.sh', '/llms.txt', '/api/', '/webhooks/', '/i/'], exempt: (request) => /(?:^|;\s*)th_session=/.test(request.headers.get('cookie') ?? ''), }); } diff --git a/apps/web/src/credits.js b/apps/web/src/credits.js new file mode 100644 index 0000000..b9b87f7 --- /dev/null +++ b/apps/web/src/credits.js @@ -0,0 +1,157 @@ +import { db } from '@tleehealth/db'; +import { Credits, postgresStore as creditStore } from '@profullstack/redeem-credits'; +import { Affiliates, postgresStore as affStore } from '@profullstack/affiliates'; + +/** + * Credit codes (@profullstack/redeem-credits) and invites (@profullstack/affiliates). + * + * What both grant is `pro_days`: days of a practice's paid plan. They land in the + * user's balance (the redeem-credits ledger) and an owner or org manager applies + * them to a practice, which moves org_billing.paid_through forward, the same + * column a CoinPay payment extends. No health data is read or written here. + */ + +export const UNIT = 'pro_days'; +export const INVITE_QUOTA = 3; +export const INVITE_BONUS_DAYS = 7; +export const INVITER_MONTHLY_CAP = 3; +export const INVITEE_MAX_AGE_DAYS = 7; +const TRIAL_DAYS = 14; +const DAY_MS = 86400_000; + +/** node-postgres shaped adapter over the app's porsager client. */ +const pool = () => ({ + query: async (text, params) => ({ rows: params?.length ? await db().unsafe(text, params) : await db().unsafe(text) }), +}); + +let credits = null; +let affiliates = null; +export const getCredits = () => (credits ??= new Credits(creditStore(pool()))); +export const getAffiliates = () => (affiliates ??= new Affiliates(affStore(pool()), { defaultQuota: INVITE_QUOTA })); + +/** Create the module tables at boot, not on a first request. */ +export async function warm() { + await getCredits().list(); + await getAffiliates().quota('00000000-0000-0000-0000-000000000000'); +} + +export const REDEEM_MESSAGES = { + invalid: 'that code is not valid', + expired: 'that code has expired', + used_up: 'that code has already been used', + already_redeemed: 'you already redeemed that code', + revoked: 'that code was revoked', +}; + +/** + * Move `days` from the user's balance onto a practice's paid period. The caller + * has checked the user is its owner or org manager. The spend is compare-and-swap + * safe; if the period write fails the days go back. + */ +export async function applyDays(userId, orgId, days) { + if (!Number.isInteger(days) || days < 1 || days > 36500) return { ok: false, error: 'pick a whole number of days' }; + const c = getCredits(); + const spent = await c.spend(userId, UNIT, days, `org:${orgId}`); + if (!spent.ok) return { ok: false, error: `you have ${spent.balance} day${spent.balance === 1 ? '' : 's'} to apply`, balance: spent.balance }; + try { + // A practice still in its free trial keeps the trial: paid time starts when it ends. + const [b] = await db()` + insert into org_billing (org_id, paid_through) + select o.id, greatest(now(), o.created_at + make_interval(days => ${TRIAL_DAYS})) + make_interval(days => ${days}) + from organizations o where o.id = ${orgId} + on conflict (org_id) do update set paid_through = + greatest(coalesce(org_billing.paid_through, now()), now(), + (select created_at from organizations where id = ${orgId}) + make_interval(days => ${TRIAL_DAYS})) + + make_interval(days => ${days}) + returning paid_through`; + if (!b) throw new Error('no such practice'); + return { ok: true, days, paid_through: b.paid_through, balance: spent.balance }; + } catch (err) { + await c.grant(userId, { unit: UNIT, amount: days }, `refund:org:${orgId}`); + throw err; + } +} + +/* ----------------------------------------------------------------- invites -- */ + +let disposable = null; +async function throwaway(email) { + disposable ??= new Set((await import('disposable-email-domains')).default); + const domain = String(email).toLowerCase().split('@')[1] ?? ''; + if (!domain) return true; + const parts = domain.split('.'); + for (let i = 0; i < parts.length - 1; i++) if (disposable.has(parts.slice(i).join('.'))) return true; + return false; +} + +/** Same mailbox: no +tags, no dots on Gmail, googlemail = gmail. */ +export function mailboxKey(email) { + let [local = '', domain = ''] = String(email).trim().toLowerCase().split('@'); + local = local.split('+')[0]; + if (domain === 'googlemail.com') domain = 'gmail.com'; + if (domain === 'gmail.com') local = local.replace(/\./g, ''); + return `${local}@${domain}`; +} + +export const ACCEPT_MESSAGES = { + not_found: 'sign in first', + account_too_old: `invites are for new accounts (first ${INVITEE_MAX_AGE_DAYS} days)`, + throwaway_email: 'invites are not open to throwaway email addresses', + duplicate_account: 'another tleehealth account already uses that mailbox', + invalid: 'that invite code is not valid', + expired: 'that invite has expired', + used: 'that invite has already been used', + self: 'you cannot accept your own invite', + revoked: 'that invite was revoked', + already_invited: 'your account has already accepted an invite', +}; + +/** Gates on the invitee. Every account here is email-verified: the emailed link made it. */ +async function gate(userId, now) { + const [u] = await db()`select id, email, created_at from users where id = ${userId}`; + if (!u) return 'not_found'; + if (now - new Date(u.created_at).getTime() > INVITEE_MAX_AGE_DAYS * DAY_MS) return 'account_too_old'; + if (await throwaway(u.email)) return 'throwaway_email'; + const key = mailboxKey(u.email); + const [local, domain] = key.split('@'); + // Candidates on the same domain (googlemail folded into gmail), compared in JS. + const others = await db()` + select email from users where id <> ${userId} + and lower(split_part(email, '@', 2)) in (${domain}, ${domain === 'gmail.com' ? 'googlemail.com' : domain})`; + if (others.some((o) => mailboxKey(o.email) === `${local}@${domain}`)) return 'duplicate_account'; + return null; +} + +/** + * Accept an invite for a new account and pay both sides INVITE_BONUS_DAYS. + * redeemInvite() is the one atomic gate (one invite per account; a second is + * refused `already_invited` and left unused); invitedBy() is never pre-checked. + */ +export async function acceptInvite(userId, code, now = Date.now()) { + const g = await gate(userId, now); + if (g) return { ok: false, reason: g }; + const r = await getAffiliates().redeemInvite(code, userId); + if (!r.ok) return { ok: false, reason: r.reason }; + + const monthStart = new Date(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1)); + const [{ n }] = await db()` + select count(*)::int as n from invite_bonuses + where inviter_id = ${r.inviterId} and inviter_paid and created_at >= ${monthStart}`; + const inviterPaid = n < INVITER_MONTHLY_CAP; + const [row] = await db()` + insert into invite_bonuses (invitee_id, inviter_id, invite_id, days, inviter_paid) + values (${userId}, ${r.inviterId}, ${r.inviteId}, ${INVITE_BONUS_DAYS}, ${inviterPaid}) + on conflict (invitee_id) do nothing returning invitee_id`; + if (!row) return { ok: true, inviterId: r.inviterId, inviteeDays: 0, inviterDays: 0 }; + const grant = { unit: UNIT, amount: INVITE_BONUS_DAYS }; + await getCredits().grant(userId, grant, `invite:${r.inviteId}:invitee`); + if (inviterPaid) await getCredits().grant(r.inviterId, grant, `invite:${r.inviteId}:inviter`); + return { ok: true, inviterId: r.inviterId, inviteeDays: INVITE_BONUS_DAYS, inviterDays: inviterPaid ? INVITE_BONUS_DAYS : 0 }; +} + +/** Invite codes are INV-XXXX-XXXX-XXXX; keep only what could be one. */ +export const cleanInvite = (v) => { + const s = String(v ?? '').replace(/[^A-Za-z0-9-]/g, '').slice(0, 40); + return s.length >= 12 ? s : null; +}; +export const INVITE_COOKIE = 'th_invite'; diff --git a/apps/web/src/main.js b/apps/web/src/main.js index 65db702..9f104e2 100644 --- a/apps/web/src/main.js +++ b/apps/web/src/main.js @@ -5,6 +5,7 @@ import { app } from './app.js'; import { startScheduler } from './calls.js'; import { enabled as callingEnabled } from './telnyx.js'; import { config } from './config.js'; +import { warm as warmCredits } from './credits.js'; // Production never runs without its database; the deploy's health check then // fails loudly instead of serving a site that cannot store anything. @@ -16,6 +17,8 @@ if (configured()) { await migrate(); // The coinpay object goes in whole: its getters read the environment on each access. configurePayments({ sql: db(), coinpay: config.coinpay, siteUrl: config.siteUrl }); + // The credit-code and invite tables, created before the first request needs them. + await warmCredits(); // The AI-call dialler: every minute, inside each practice's calling hours. if (process.env.CALLS_SCHEDULER !== 'off') startScheduler(); } diff --git a/bun.lock b/bun.lock index a66d690..7791d59 100644 --- a/bun.lock +++ b/bun.lock @@ -9,18 +9,21 @@ "name": "@tleehealth/web", "version": "0.1.2", "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", }, }, "packages/cli": { "name": "@profullstack/tleehealth", - "version": "0.4.2", + "version": "0.5.0", "bin": { "tleehealth": "./bin/tleehealth.mjs", }, @@ -38,12 +41,12 @@ }, "packages/mcp": { "name": "@profullstack/tleehealth-mcp", - "version": "0.4.2", + "version": "0.5.0", "bin": { "tleehealth-mcp": "./bin/tleehealth-mcp.mjs", }, "dependencies": { - "@profullstack/tleehealth": "^0.4.2", + "@profullstack/tleehealth": "^0.5.0", }, }, "packages/payments": { @@ -86,6 +89,8 @@ "@peculiar/x509": ["@peculiar/x509@2.2.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-csr": "^2.11.0", "@peculiar/asn1-ecc": "^2.11.0", "@peculiar/asn1-pkcs9": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-post-quantum": "^2.11.0", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q=="], + "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], + "@profullstack/hqtui": ["@profullstack/hqtui@0.11.1", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-c/UzTmiRDjdLZQEO/gdVinUKtx6Q0WBPdixIm90MBTvC3DqFPqpersRs0BIDPS7XVokCSr1DtELI91kj5J1kCg=="], "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], @@ -94,6 +99,10 @@ "@profullstack/profile": ["@profullstack/profile@0.1.6", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-xkn0P3ANz151QJdlBwcBXkHk813qW9qAmuTZKcuvpEZYkyHDC9J9KfvpVSRepfjECzz4ONGHWO5Zwc4BHpfK8g=="], + "@profullstack/redeem-credits": ["@profullstack/redeem-credits@0.2.0", "", { "bin": { "redeem-credits": "dist/cli.js" } }, "sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog=="], + + "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], + "@profullstack/tleehealth": ["@profullstack/tleehealth@workspace:packages/cli"], "@profullstack/tleehealth-mcp": ["@profullstack/tleehealth-mcp@workspace:packages/mcp"], @@ -112,6 +121,8 @@ "asn1js": ["asn1js@3.1.0", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="], + "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], + "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index f6441aa..1aa9306 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tleehealth", - "version": "0.4.2", + "version": "0.5.0", "description": "tleehealth from the terminal: the CLI, the front-desk TUI, and the API client the MCP server uses.", "type": "module", "license": "MIT", diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js index 3b41579..2918de6 100644 --- a/packages/cli/src/cli.js +++ b/packages/cli/src/cli.js @@ -27,6 +27,7 @@ import { createInterface } from 'node:readline/promises'; import { caseload, connect, connection, disconnect, exportRecords, health, logTime, providers, recordAccess, recordFile, recordItems, records, resolveAuth, saveConfig, schedule, shareConnection, superbill, syncConnection, + applyCredits, createInvite, creditBalance, invites, redeemCode, } from './client.js'; const HELP = `tleehealth: telehealth and practice management. @@ -47,6 +48,12 @@ Your health records from other providers (MyChart and any SMART on FHIR portal): tleehealth access who at which practice opened your shared records tleehealth disconnect CONNECTION_ID remove it and everything it imported +Credit codes and invites (days of a practice's paid plan): + tleehealth redeem CODE [--org ORG] [--keep] redeem a code onto a practice (--keep: into your balance) + tleehealth credits [--apply DAYS] [--org ORG] your balance; --apply moves days onto a practice + tleehealth invite [--note TEXT] make an invite link (3 per account) + tleehealth invites [--json] your invites and what is left + tleehealth health is the API up tleehealth login [KEY] save an API key tleehealth dashboard the dashboard, as a screen (alias: tui) @@ -299,6 +306,42 @@ export async function main(argv = process.argv.slice(2)) { await save(r.bytes, typeof flags.out === 'string' ? flags.out : (r.filename ?? id)); return 0; } + case 'redeem': { + if (!rest[0]) throw new Error('usage: tleehealth redeem CODE [--org ORG] [--keep]'); + const r = await redeemCode(auth, { code: rest[0], org: flags.org, keep: Boolean(flags.keep) }); + console.log( + r.applied?.ok + ? `Added ${r.applied.days} days. Paid through ${String(r.applied.paid_through).slice(0, 10)}.` + : `Added ${r.grant.amount} days to your balance (${r.balance} now).${r.applied?.error ? ` ${r.applied.error}` : ''}`, + ); + return 0; + } + case 'credits': { + if (flags.apply) { + const r = await applyCredits(auth, { org: flags.org, days: flags.apply }); + console.log(`Added ${r.days} days. Paid through ${String(r.paid_through).slice(0, 10)}. Balance ${r.balance}.`); + return 0; + } + const c = await creditBalance(auth); + console.log(flags.json ? JSON.stringify(c, null, 2) : `${c.balance} ${c.unit.replace('_', ' ')}`); + return 0; + } + case 'invite': { + const r = await createInvite(auth, { note: flags.note }); + console.log(`${r.invite.url} +(shown once; ${r.quota.left} of ${r.quota.total} left)`); + return 0; + } + case 'invites': { + const r = await invites(auth); + if (flags.json) console.log(JSON.stringify(r, null, 2)); + else { + console.log(`${r.quota.left} of ${r.quota.total} invites left. Each gives you both ${r.bonus_days} days.`); + for (const i of r.invites) + console.log(` ${new Date(i.createdAt).toISOString().slice(0, 10)} ${(i.note || 'invite').padEnd(24)} ${i.revokedAt ? 'revoked' : i.usedAt ? 'accepted' : 'open'}`); + } + return 0; + } case 'dashboard': case 'tui': { const { runTui } = await import('./tui.js'); diff --git a/packages/cli/src/client.js b/packages/cli/src/client.js index bbd90dd..7220fe7 100644 --- a/packages/cli/src/client.js +++ b/packages/cli/src/client.js @@ -182,3 +182,26 @@ export const recordFile = (auth, id) => download(auth, `/api/v1/records/files/${ /** Who at which practice viewed, downloaded or exported the records you shared. */ export const recordAccess = (auth) => call(auth, '/api/v1/records/access'); + +/* ------------------------------------------------- credit codes + invites -- */ + +const postJson = (auth, path, body) => + call(auth, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) }); + +/** Your balance of pro_days (days of a practice's paid plan) and its history. */ +export const creditBalance = (auth) => call(auth, '/api/v1/credits'); + +/** Redeem a credit code; with an org (yours to administer) the days go straight onto its plan. */ +export async function redeemCode(auth, { code, org, keep = false } = {}) { + const orgId = keep ? undefined : await resolveOrg(auth, org).catch(() => undefined); + return postJson(auth, '/api/v1/credits/redeem', { code, ...(orgId ? { org_id: orgId } : {}) }); +} + +/** Move days from your balance onto a practice's paid plan. */ +export async function applyCredits(auth, { org, days } = {}) { + const id = await resolveOrg(auth, org); + return postJson(auth, `/api/v1/orgs/${id}/billing/apply-credits`, { days: Number(days) }); +} + +export const invites = (auth) => call(auth, '/api/v1/invites'); +export const createInvite = (auth, { note } = {}) => postJson(auth, '/api/v1/invites', { note }); diff --git a/packages/db/migrations/0008_credits_invites.sql b/packages/db/migrations/0008_credits_invites.sql new file mode 100644 index 0000000..3c59063 --- /dev/null +++ b/packages/db/migrations/0008_credits_invites.sql @@ -0,0 +1,20 @@ +-- Credit codes + invites. The credit_* and aff_* tables belong to +-- @profullstack/redeem-credits and @profullstack/affiliates (their postgres +-- stores create them; apps/web/src/credits.js warms them at boot). What a code +-- or invite grants is pro_days: days added to an org's org_billing.paid_through, +-- the same column a CoinPay payment extends. + +-- The invite a signup carried, kept on the emailed link so opening it on +-- another device still counts. Accepted only when that link creates the account. +alter table login_tokens add column if not exists invite text; + +-- One bonus per invited account, however often anything retries. +create table if not exists invite_bonuses ( + invitee_id uuid primary key references users(id) on delete cascade, + inviter_id uuid not null references users(id) on delete cascade, + invite_id text not null, + days int not null, + inviter_paid boolean not null default false, + created_at timestamptz not null default now() +); +create index if not exists invite_bonuses_inviter_idx on invite_bonuses (inviter_id, created_at); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 66ec48d..2fe6204 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tleehealth-mcp", - "version": "0.4.2", + "version": "0.5.0", "description": "MCP server for tleehealth.com: read the practice schedule, the navigation caseload and superbill, and log navigation time from any MCP client.", "type": "module", "license": "MIT", @@ -26,7 +26,7 @@ "README.md" ], "dependencies": { - "@profullstack/tleehealth": "^0.4.2" + "@profullstack/tleehealth": "^0.5.0" }, "keywords": [ "mcp", diff --git a/packages/mcp/src/server.js b/packages/mcp/src/server.js index cb86a44..7d42987 100644 --- a/packages/mcp/src/server.js +++ b/packages/mcp/src/server.js @@ -4,6 +4,7 @@ */ import { createInterface } from 'node:readline'; import { + call, resolveOrg, caseload, connect, dashboardDay, health, logTime, providers, recordItem, recordItems, records, resolveAuth, schedule, superbill, syncConnection, } from '@profullstack/tleehealth/client'; @@ -128,6 +129,27 @@ const TOOLS = [ description: 'Import again from one connected provider (or all when connection is omitted) and wait for it to finish.', inputSchema: { type: 'object', properties: { connection: { type: 'string' } }, additionalProperties: false }, }, + { + name: 'redeem_credit_code', + description: + "Redeem a tleehealth credit code (CR-XXXX-XXXX-XXXX-XXXX) for days of a practice's paid plan. With org (or by default your first practice you administer) the days go onto that practice; keep=true puts them in your balance.", + inputSchema: { + type: 'object', + properties: { code: { type: 'string' }, org: { type: 'string', description: 'practice id' }, keep: { type: 'boolean' } }, + required: ['code'], + additionalProperties: false, + }, + }, + { + name: 'get_credits', + description: "Your balance of pro_days (days of a practice's paid plan from codes and invites) and its history.", + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'create_invite', + description: 'Make an invite link (3 per account). When someone new signs up with it you both get days of the paid plan. The link is shown once.', + inputSchema: { type: 'object', properties: { note: { type: 'string' } }, additionalProperties: false }, + }, { name: 'api_health', description: 'Whether the tleehealth API is up, and its version.', @@ -171,6 +193,18 @@ async function callTool(name, args) { for (const id of ids) out.push({ connection: id, ...(await syncConnection(auth, id, { wait: true })) }); return textResult(out); } + case 'redeem_credit_code': { + const org = args.keep ? undefined : await resolveOrg(auth, args.org).catch(() => undefined); + return textResult(await call(auth, '/api/v1/credits/redeem', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: args.code, ...(org ? { org_id: org } : {}) }), + })); + } + case 'get_credits': + return textResult(await call(auth, '/api/v1/credits')); + case 'create_invite': + return textResult(await call(auth, '/api/v1/invites', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ note: args.note }), + })); case 'api_health': return textResult(await health(auth)); default: diff --git a/test/client.test.js b/test/client.test.js index 4dd4208..8be2b33 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -37,7 +37,7 @@ describe('cli', () => { describe('mcp', () => { test('lists tools', async () => { const { tools } = await handle({ method: 'tools/list' }); - expect(tools.map((t) => t.name)).toEqual(['get_schedule', 'get_caseload', 'get_superbill', 'log_navigation_time', 'get_dashboard', 'get_health_records', 'list_health_records', 'get_health_record', 'find_health_providers', 'connect_health_provider', 'sync_health_records', 'api_health']); + expect(tools.map((t) => t.name)).toEqual(['get_schedule', 'get_caseload', 'get_superbill', 'log_navigation_time', 'get_dashboard', 'get_health_records', 'list_health_records', 'get_health_record', 'find_health_providers', 'connect_health_provider', 'sync_health_records', 'redeem_credit_code', 'get_credits', 'create_invite', 'api_health']); }); test('initialize', async () => { expect((await handle({ method: 'initialize' })).serverInfo.name).toBe('tleehealth'); diff --git a/test/credits.test.js b/test/credits.test.js new file mode 100644 index 0000000..4c0f93d --- /dev/null +++ b/test/credits.test.js @@ -0,0 +1,134 @@ +// Credit codes + invites end to end through the app against a real Postgres. +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { configured, close, db } from '../packages/db/src/index.js'; +import { migrate } from '../packages/db/src/migrate.js'; + +const { app } = configured() ? await import('../apps/web/src/app.js') : { app: null }; +const auth = configured() ? await import('../apps/web/src/auth.js') : null; +const credits = configured() ? await import('../apps/web/src/credits.js') : null; + +async function magic(email, opts) { + const url = new URL(await auth.createLoginLink(email, opts)); + return app.request(`/auth/magic?t=${url.searchParams.get('t')}`); +} +const cookieOf = (res) => res.headers.get('set-cookie').split(';')[0]; + +function client(cookie) { + const call = async (method, path, body) => { + const res = await app.request(`/api/v1${path}`, { + method, + headers: { cookie, 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; + }; + return { get: (p) => call('GET', p), post: (p, b = {}) => call('POST', p, b) }; +} + +describe.skipIf(!configured())('credit codes + invites', () => { + const tag = Date.now(); + let owner, orgId; + + beforeAll(async () => { + await migrate({ log: () => {} }); + await credits.warm(); + owner = client(cookieOf(await magic(`credowner${tag}@example.com`))); + orgId = (await owner.post('/orgs', { name: `Credit Practice ${tag}` })).body.org.id; + }); + afterAll(() => close()); + + test('a code redeemed for a practice extends paid_through past the trial', async () => { + const { code } = await credits.getCredits().mint({ grant: { unit: credits.UNIT, amount: 30 } }); + const r = await owner.post('/credits/redeem', { code, org_id: orgId }); + expect(r.status).toBe(200); + expect(r.body.applied.ok).toBe(true); + const days = (new Date(r.body.applied.paid_through) - Date.now()) / 86400_000; + expect(days).toBeGreaterThan(43.9); // 14 trial + 30 + expect(days).toBeLessThan(44.1); + expect((await owner.get(`/orgs/${orgId}/billing`)).body.billing.paid_through).toBeTruthy(); + + const again = await owner.post('/credits/redeem', { code }); + expect(again.status).toBe(400); + expect(again.body.reason).toBe('already_redeemed'); + }); + + test('a bad practice never burns the code', async () => { + const { code } = await credits.getCredits().mint({ grant: { unit: credits.UNIT, amount: 5 } }); + const other = client(cookieOf(await magic(`credother${tag}@example.com`))); + const r = await other.post('/credits/redeem', { code, org_id: orgId }); + expect(r.status).toBe(404); + expect((await owner.post('/credits/redeem', { code })).body.balance).toBe(5); + const apply = await owner.post(`/orgs/${orgId}/billing/apply-credits`, { days: 6 }); + expect(apply.status).toBe(400); + expect((await owner.post(`/orgs/${orgId}/billing/apply-credits`, { days: 5 })).body.ok).toBe(true); + expect((await owner.get('/credits')).body.balance).toBe(0); + }); + + test('an invite on the signup link is accepted when that link creates the account', async () => { + const inv = await owner.post('/invites', { note: 'friend' }); + expect(inv.status).toBe(201); + expect(inv.body.invite.url).toContain(`/i/${inv.body.invite.code}`); + const res = await magic(`credfriend${tag}@example.com`, { invite: inv.body.invite.code }); + expect(res.headers.get('location')).toBe('/app/invites?joined=1'); + const friend = client(cookieOf(res)); + expect((await friend.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS); + expect((await owner.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS); + // signing in again later with another invite does nothing + const inv2 = await owner.post('/invites', {}); + const again = await magic(`credfriend${tag}@example.com`, { invite: inv2.body.invite.code }); + expect(again.headers.get('location')).toBe('/app'); + }); + + test('concurrent double-accept: one wins, the other is already_invited and stays unused', async () => { + const a = client(cookieOf(await magic(`credinvA${tag}@example.com`))); + const b = client(cookieOf(await magic(`credinvB${tag}@example.com`))); + const newbie = client(cookieOf(await magic(`crednew${tag}@example.com`))); + const ca = (await a.post('/invites', {})).body.invite.code; + const cb = (await b.post('/invites', {})).body.invite.code; + const rs = await Promise.all([newbie.post('/invites/accept', { code: ca }), newbie.post('/invites/accept', { code: cb })]); + expect(rs.filter((r) => r.status === 200)).toHaveLength(1); + const lost = rs.find((r) => r.status !== 200); + expect(lost.body.reason).toBe('already_invited'); + expect((await newbie.get('/credits')).body.balance).toBe(credits.INVITE_BONUS_DAYS); + const loser = rs[0].status === 200 ? b : a; + expect((await loser.get('/invites')).body.invites[0].usedBy).toBeNull(); + expect((await loser.get('/credits')).body.balance).toBe(0); + }); + + test('gates: throwaway domains, mailbox aliases, self, old accounts', async () => { + const inviter = client(cookieOf(await magic(`credgate${tag}@example.com`))); + const code = (await inviter.post('/invites', {})).body.invite.code; + const throw_ = client(cookieOf(await magic(`x${tag}@mailinator.com`))); + expect((await throw_.post('/invites/accept', { code })).body.reason).toBe('throwaway_email'); + expect((await inviter.post('/invites/accept', { code })).body.reason).toBe('self'); + const alias = client(cookieOf(await magic(`credgate${tag}+2@example.com`))); + expect((await alias.post('/invites/accept', { code })).body.reason).toBe('duplicate_account'); + const old = client(cookieOf(await magic(`credold${tag}@example.com`))); + await db()`update users set created_at = now() - interval '8 days' where email = ${`credold${tag}@example.com`}`; + expect((await old.post('/invites/accept', { code })).body.reason).toBe('account_too_old'); + expect((await inviter.get('/invites')).body.invites[0].usedBy).toBeNull(); + }); + + test('/i/ keeps the invite and links to signup', async () => { + const res = await app.request('/i/INV-ABCD-EFGH-JKMN'); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toContain('th_invite=INV-ABCD-EFGH-JKMN'); + expect(await res.text()).toContain('/signup?invite=INV-ABCD-EFGH-JKMN'); + expect((await app.request('/invites')).headers.get('location')).toBe('/app/invites'); + }); + + test('/auth/link stores the cookie invite on the emailed link', async () => { + const email = `credlink${tag}@example.com`; + await app.request('/api/v1/auth/link', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: 'th_invite=INV-ZZZZ-ZZZZ-ZZZZ' }, + body: JSON.stringify({ email }), + }); + const [row] = await db()`select invite from login_tokens where email = ${email}`; + expect(row.invite).toBe('INV-ZZZZ-ZZZZ-ZZZZ'); + }); + + test('mailboxKey folds +tags and Gmail dots', () => { + expect(credits.mailboxKey('J.Doe+x@GoogleMail.com')).toBe('jdoe@gmail.com'); + }); +});