Files

618 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* REWARDS: the one points system, as hanzo-inc/cloud apps/rewards answers it.
* The shapes below are the cloud's Go types (rules.go, board.go, me.go,
* redeem.go, share.go), their json tags the field names.
*
* GET /v1/rewards/rules what earns, what each earns, caps, levels, badges (public)
* GET /v1/rewards the board (?board=&period=all|month&limit=); a bearer adds `you`
* GET /v1/rewards/me the reader's standing, badges, history, posts
* GET /v1/rewards/optin whether the reader's row carries their name
* PUT /v1/rewards/optin list the reader's row under a handle, or take the name off
* POST /v1/rewards/redeem {dollars}: eligible points into service credit, capped
* GET /v1/rewards/redemptions the reader's redemptions, newest first
* POST /v1/rewards/posts {url}: a public post carrying the reader's link, held pending
* GET /v1/rewards/posts the reader's posts and what the pending ones are worth
* GET /v1/rewards/badges/<id> a badge's metadata (ERC-721 JSON)
* GET /v1/rewards/badges/<id>/art a badge's art (SVG)
* POST /v1/rewards/claim {badge, wallet}: a badge claimed on the rules' chain, once minting is on
* GET /v1/rewards/cyber the standing on the invite-only track; 404 for everyone not invited
*
* Points are reputation: non-transferable, held by one account. Only the kinds
* the rules mark `credit` redeem, for non-transferable Hanzo AI service credit
* with no cash value. Every page that says what earns what prints these rules:
* the build's snapshot (lib/data/rewards-rules.json, scripts/sync-rewards.ts)
* and, once the page is open, the endpoint's own answer.
*
* A GitHub account is linked through the person's own GitHub connection
* (/v1/provider/github/user): GitHub sends the browser back to
* https://hanzo.ai/leaderboard with a grant, and the page completes it.
*/
import { api } from '@hanzo/app/lib/api'
import { scope } from '@hanzo/app/lib/session'
import snapshot from '@/lib/data/rewards-rules.json' with { type: 'json' }
export interface Source {
id: string
name: string
}
/** One way to earn. A kind that is not `live` is part of the program and awards nothing yet. */
export interface Kind {
id: string
source: string
points: number
title: string
rule: string
/** Its points redeem for service credit. */
credit: boolean
/** Only Hanzo awards it, after review. */
manual: boolean
/** Never on the public board. */
private: boolean
live: boolean
}
/** A token milestone: awarded once, the first time recorded usage reaches `tokens`. */
export interface Tier {
tokens: number
points: number
badge: string
}
/** The most one holder earns from a source in a window: `day` (UTC) or `30d` (rolling). */
export interface Cap {
source: string
limit: number
window: string
}
/** A level, reached at `points` all time; a `substantive` one also needs reviewed work that is not a referral. */
export interface Level {
name: string
points: number
substantive: boolean
}
/** A collectible, unlocked by `count` awards of `kinds` (of `ref`, when named), or by lifetime `points`. */
export interface Badge {
id: string
name: string
group: string
rarity: string
rule: string
kinds: string[]
ref: string
count: number
points: number
live: boolean
}
export interface Mission {
id: string
name: string
goal: string
badge: string
}
/** One view of the board: the sources it ranks by, every public one when none. */
export interface Tab {
id: string
name: string
sources: string[]
}
/** A place a post can be pasted from; one that is not `verified` waits for a maintainer's review. */
export interface Network {
id: string
name: string
verified: boolean
}
export interface Redeem {
enabled: boolean
pointsPerDollar: number
memberMonthCents: number
programMonthCents: number
currency: string
}
/** Where a badge is claimed: an empty contract is no chain yet, and `minting` is false. */
export interface Chain {
id: number
name: string
coin: string
rpc: string
contract: string
minting: boolean
}
export interface Rules {
version: string
sources: Source[]
kinds: Kind[]
tiers: Tier[]
caps: Cap[]
levels: Level[]
badges: Badge[]
missions: Mission[]
tabs: Tab[]
github: { orgs: string[]; labels: Record<string, string[]>; staff: boolean }
activity: { retainDays: number }
share: { days: number; perWindow: number; networks: Network[] }
redeem: Redeem
payouts: boolean
chain: Chain
/** What a member who posts about Hanzo for points says. */
disclosure: string
/** What never earns, in plain words. */
excluded: string[]
/** When this program took over from the beta board, RFC 3339; empty before. */
cutover: string
}
/** One ranked row. `name` is null for a person who did not list their name. */
export interface Row {
rank: number
name: string | null
you: boolean
points: number
level: string
since: string
}
export interface Board {
rows: Row[]
you: Row | null
of: number
source: string
board: string
period: string
/** Which measured sources the last settle read. */
settled: { usage: boolean; referrals: boolean }
}
export interface Earned {
id: string
name: string
earned: boolean
at: string
}
/** One award in the reader's history. */
export interface Entry {
kind: string
source: string
points: number
ref: string
note: string
at: string
}
/** What a member may still redeem this month; `open` is whether the program's budget has room for a dollar. */
export interface Allowance {
monthCents: number
usedCents: number
leftCents: number
open: boolean
}
/** A post pasted for points: pending until it has stayed up its week, then confirmed or removed. */
export interface Post {
id: string
network: string
url: string
status: 'pending' | 'confirmed' | 'removed' | 'review' | string
points: number
at: string
countsAt: string
daysLeft: number
note: string
}
/** A standing on the track only some members are invited to; its names are the cloud's. */
export interface Track {
points: number
level: { name: string; points: number } | null
next: { name: string; points: number } | null
toNext: number
badges: Earned[]
}
export interface Me {
rank: number
of: number
points: number
since: string
level: { name: string; points: number }
next: { name: string; points: number } | null
/** Points to pass the row above; 0 at the top. */
toNext: number
sources: Record<string, number>
badges: Earned[]
redeemable: number
allowance: Allowance
legacy: number
private: number
staff: boolean
usage: { tokens: number; next: Tier | null } | null
github: { login: string; linked: boolean }
history: Entry[]
posts: Post[]
/** What the posts still waiting out their week are worth; never in `points`. */
pending: number
}
export interface Listing {
listed: boolean
handle: string
canSet: boolean
}
/** One redemption: `reserved` while the credit is granted, then `granted`, or `failed` with the points returned. */
export interface Redemption {
id: string
points: number
cents: number
status: 'reserved' | 'granted' | 'failed' | string
at: string
redeemable: number
allowance: Allowance | null
}
/** The page GitHub returns to; the cloud names it among the brand's return pages. */
export const RETURN = 'https://hanzo.ai/leaderboard'
/** All time is the record the levels count; this month is the competition. */
export type Period = 'all' | 'month'
const list = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const yes = (v: unknown): boolean => v === true
type Raw = Record<string, unknown>
const obj = (v: unknown): Raw => (v && typeof v === 'object' ? (v as Raw) : {})
/** A rules table, or null for anything that is not one. Absent fields read as their zero, never as a guess. */
export function asRules(raw: unknown): Rules | null {
const r = obj(raw)
if (!Array.isArray(r.kinds) || !r.kinds.length || !Array.isArray(r.sources) || !Array.isArray(r.levels) || !Array.isArray(r.tabs)) return null
const gh = obj(r.github)
const share = obj(r.share)
const d = obj(r.redeem)
const c = obj(r.chain)
return {
version: str(r.version),
sources: list<Raw>(r.sources).map((s) => ({ id: str(s.id), name: str(s.name) })),
kinds: list<Raw>(r.kinds).map((k) => ({
id: str(k.id),
source: str(k.source),
points: num(k.points),
title: str(k.title),
rule: str(k.rule),
credit: yes(k.credit),
manual: yes(k.manual),
private: yes(k.private),
live: yes(k.live),
})),
tiers: list<Raw>(r.tiers).map((t) => ({ tokens: num(t.tokens), points: num(t.points), badge: str(t.badge) })),
caps: list<Raw>(r.caps).map((x) => ({ source: str(x.source), limit: num(x.limit), window: str(x.window) })),
levels: list<Raw>(r.levels).map((l) => ({ name: str(l.name), points: num(l.points), substantive: yes(l.substantive) })),
badges: list<Raw>(r.badges).map((b) => ({
id: str(b.id),
name: str(b.name),
group: str(b.group),
rarity: str(b.rarity),
rule: str(b.rule),
kinds: list<string>(b.kinds),
ref: str(b.ref),
count: num(b.count),
points: num(b.points),
live: yes(b.live),
})),
missions: list<Raw>(r.missions).map((m) => ({ id: str(m.id), name: str(m.name), goal: str(m.goal), badge: str(m.badge) })),
tabs: list<Raw>(r.tabs).map((t) => ({ id: str(t.id), name: str(t.name), sources: list<string>(t.sources) })),
github: { orgs: list<string>(gh.orgs), labels: obj(gh.labels) as Record<string, string[]>, staff: yes(gh.staff) },
activity: { retainDays: num(obj(r.activity).retainDays) },
share: { days: num(share.days), perWindow: num(share.perWindow), networks: list<Raw>(share.networks).map((n) => ({ id: str(n.id), name: str(n.name), verified: yes(n.verified) })) },
redeem: {
enabled: yes(d.enabled),
pointsPerDollar: num(d.pointsPerDollar),
memberMonthCents: num(d.memberMonthCents),
programMonthCents: num(d.programMonthCents),
currency: str(d.currency) || 'usd',
},
payouts: yes(r.payouts),
chain: { id: num(c.id), name: str(c.name), coin: str(c.coin), rpc: str(c.rpc), contract: str(c.contract), minting: yes(c.minting) },
disclosure: str(r.disclosure),
excluded: list<string>(r.excluded),
cutover: str(r.cutover),
}
}
/** The build's snapshot of the rules: what a page prints before the endpoint answers. */
export const SNAPSHOT: Rules = asRules(snapshot) as Rules
/** The rules as the cloud states them now; null when it does not answer one. */
export async function rules(): Promise<Rules | null> {
try {
const res = await fetch(`${api()}/v1/rewards/rules`)
return res.ok ? asRules(await res.json()) : null
} catch {
return null
}
}
/** The sentence a cloud refusal carries: RFC 9457's `detail`, or a fallback. */
async function why(res: Response, fallback: string): Promise<string> {
const b = obj(await res.json().catch(() => ({})))
return str(b.detail) || str(b.message) || str(b.error) || `${fallback} (${res.status})`
}
const row = (raw: unknown): Row | null => {
const r = obj(raw)
if (typeof r.rank !== 'number') return null
return { rank: r.rank, name: str(r.name) || null, you: yes(r.you), points: num(r.points), level: str(r.level), since: str(r.since) }
}
/** The board for a tab of the rules and a period; null when the cloud does not answer one. */
export async function board(tab = '', period: Period = 'all', limit = 25): Promise<Board | null> {
const q = new URLSearchParams({ limit: String(limit), period })
if (tab) q.set('board', tab)
try {
const res = await fetch(`${api()}/v1/rewards?${q}`, { headers: scope() })
if (!res.ok) return null
const b = obj(await res.json())
const s = obj(b.settled)
return {
rows: list<unknown>(b.rows).map(row).filter((r): r is Row => r !== null),
you: row(b.you),
of: num(b.of),
source: str(b.source),
board: str(b.board),
period: str(b.period) || period,
settled: { usage: yes(s.usage), referrals: yes(s.referrals) },
}
} catch {
return null
}
}
const earned = (raw: unknown): Earned[] => list<Raw>(raw).map((b) => ({ id: str(b.id), name: str(b.name), earned: yes(b.earned), at: str(b.at) }))
const step = (raw: unknown): { name: string; points: number } | null => {
const l = obj(raw)
return typeof l.name === 'string' && l.name ? { name: l.name, points: num(l.points) } : null
}
const allowance = (raw: unknown): Allowance => {
const a = obj(raw)
return { monthCents: num(a.monthCents), usedCents: num(a.usedCents), leftCents: num(a.leftCents), open: yes(a.open) }
}
/** A post as the cloud answers it. */
export const asPost = (raw: unknown): Post => {
const p = obj(raw)
return { id: str(p.id), network: str(p.network), url: str(p.url), status: str(p.status), points: num(p.points), at: str(p.at), countsAt: str(p.countsAt), daysLeft: num(p.daysLeft), note: str(p.note) }
}
/** The reader's standing; null signed out or when the cloud does not answer. */
export async function me(): Promise<Me | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/me`, { headers })
if (!res.ok) return null
const m = obj(await res.json())
const usage = m.usage && typeof obj(m.usage).tokens === 'number' ? obj(m.usage) : null
const next = usage ? obj(usage.next) : null
const gh = obj(m.github)
return {
rank: num(m.rank),
of: num(m.of),
points: num(m.points),
since: str(m.since),
level: step(m.level) ?? { name: '', points: 0 },
next: step(m.next),
toNext: num(m.toNext),
sources: obj(m.sources) as Record<string, number>,
badges: earned(m.badges),
redeemable: num(m.redeemable),
allowance: allowance(m.allowance),
legacy: num(m.legacy),
private: num(m.private),
staff: yes(m.staff),
usage: usage ? { tokens: num(usage.tokens), next: next && typeof next.tokens === 'number' ? { tokens: num(next.tokens), points: num(next.points), badge: str(next.badge) } : null } : null,
github: { login: str(gh.login), linked: yes(gh.linked) },
history: list<Raw>(m.history).map((e) => ({ kind: str(e.kind), source: str(e.source), points: num(e.points), ref: str(e.ref), note: str(e.note), at: str(e.at) })),
posts: list<unknown>(m.posts).map(asPost),
pending: num(m.pending),
}
} catch {
return null
}
}
const json = { 'Content-Type': 'application/json' }
/** Whether the reader's row carries their name. */
export async function listing(): Promise<Listing | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/optin`, { headers })
if (!res.ok) return null
const u = obj(obj(await res.json()).user)
return { listed: yes(u.listed), handle: str(u.handle), canSet: yes(u.canSet) }
} catch {
return null
}
}
/** List the reader's row under a handle (their username when none is given), or take the name off. */
export async function setListing(listed: boolean, handle = ''): Promise<Listing | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/optin`, { method: 'PUT', headers: { ...json, ...headers }, body: JSON.stringify({ listed, handle }) })
if (!res.ok) return null
const b = obj(await res.json())
return { listed: yes(b.listed), handle: str(b.handle), canSet: true }
} catch {
return null
}
}
const redemption = (raw: unknown): Redemption => {
const r = obj(raw)
return { id: str(r.id), points: num(r.points), cents: num(r.cents), status: str(r.status), at: str(r.at), redeemable: num(r.redeemable), allowance: r.allowance ? allowance(r.allowance) : null }
}
/** Redeem whole dollars of service credit. Throws with the cloud's reason when it refuses. */
export async function redeem(dollars: number): Promise<Redemption> {
const res = await fetch(`${api()}/v1/rewards/redeem`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ dollars }) })
if (!res.ok) throw new Error(await why(res, 'The points could not be redeemed'))
return redemption(await res.json())
}
/** The reader's redemptions, newest first; null signed out or unanswered. */
export async function redemptions(): Promise<Redemption[] | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/redemptions`, { headers })
return res.ok ? list<unknown>(obj(await res.json()).redemptions).map(redemption) : null
} catch {
return null
}
}
/** The reader's posts and what the pending ones are worth; null signed out or unanswered. */
export async function posts(): Promise<{ posts: Post[]; pending: number } | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/posts`, { headers })
if (!res.ok) return null
const b = obj(await res.json())
return { posts: list<unknown>(b.posts).map(asPost), pending: num(b.pending) }
} catch {
return null
}
}
/** Paste a post for points. Throws with the cloud's reason when it refuses one. */
export async function post(url: string): Promise<Post> {
const res = await fetch(`${api()}/v1/rewards/posts`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ url }) })
if (!res.ok) throw new Error(await why(res, 'The post could not be added'))
return asPost(await res.json())
}
/** Claim an unlocked badge on the rules' chain, to a wallet verified on the account. Only offered while `chain.minting` is true. */
export async function claim(badge: string, wallet: string): Promise<void> {
const res = await fetch(`${api()}/v1/rewards/claim`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ badge, wallet }) })
if (!res.ok) throw new Error(await why(res, 'The badge could not be claimed'))
}
/** The reader's standing on the invite-only track; null for everyone the cloud does not let in (it answers 404). */
export async function track(): Promise<Track | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/rewards/cyber`, { headers })
if (!res.ok) return null
const v = obj(await res.json())
return { points: num(v.points), level: step(v.level), next: step(v.next), toNext: num(v.toNext), badges: earned(v.badges) }
} catch {
return null
}
}
/** A badge's art, as the cloud draws it. */
export const art = (id: string): string => `${api()}/v1/rewards/badges/${encodeURIComponent(id)}/art`
/** The reader's GitHub connection: whether this deployment can connect one, and which account is linked. */
export interface Connection {
configured: boolean
connected: boolean
login: string
}
const connection = (raw: unknown): Connection => {
const c = obj(raw)
return { configured: yes(c.configured), connected: yes(c.connected), login: str(c.login) }
}
/** The reader's GitHub connection; null signed out or unanswered. */
export async function github(): Promise<Connection | null> {
const headers = scope()
if (!headers.Authorization) return null
try {
const res = await fetch(`${api()}/v1/provider/github/user`, { headers })
return res.ok ? connection(await res.json()) : null
} catch {
return null
}
}
/** Where to send the reader to authorize the link; throws with the cloud's reason when it refuses. */
export async function connect(): Promise<string> {
const res = await fetch(`${api()}/v1/provider/github/user/connect`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ return: RETURN }) })
if (!res.ok) throw new Error(await why(res, 'GitHub could not be linked'))
const to = str(obj(await res.json()).authorizeUrl)
if (!to) throw new Error('GitHub could not be linked')
return to
}
/** Finish the link GitHub returned with; throws with the cloud's reason when it refuses. */
export async function complete(grant: string): Promise<Connection> {
const res = await fetch(`${api()}/v1/provider/github/user/complete`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ grant }) })
if (!res.ok) throw new Error(await why(res, 'GitHub could not be linked'))
return connection(await res.json())
}
/** A token count as the milestones name it: 100K, 1M, 3.6M, 1T. */
export function tokens(n: number): string {
const units: [number, string][] = [
[1e12, 'T'],
[1e9, 'B'],
[1e6, 'M'],
[1e3, 'K'],
]
for (const [size, unit] of units) if (n >= size) return `${Number((n / size).toFixed(n % size === 0 ? 0 : 1))}${unit}`
return String(n)
}
/** A kind of the rules, by id. */
export const kind = (r: Rules, id: string): Kind | undefined => r.kinds.find((k) => k.id === id)
/** A source's name as the rules give it, or its id. */
export const sourceName = (r: Rules, id: string): string => r.sources.find((s) => s.id === id)?.name ?? id
/** What one kind earns, as a tile says it: +20, or a milestone's range. */
export function worth(k: Kind, r: Rules): string {
if (k.id === 'tier' && r.tiers.length) {
const pts = r.tiers.map((t) => t.points)
return `+${Math.min(...pts)}–${Math.max(...pts)}`
}
return `+${k.points}`
}
/** The ledger's own bookkeeping, priced per unit: kept points, reversals and redemptions, never a way to earn. */
const BOOKS = ['legacy', 'reversal', 'redeem', 'unredeem']
/** The kinds of one source a page lists as ways to earn. */
export const kindsOf = (r: Rules, source: string): Kind[] => r.kinds.filter((k) => k.source === source && !BOOKS.includes(k.id))
/** The sources a page lists as ways to earn: each that holds such a kind, in the rules' order. */
export const earning = (r: Rules): Source[] => r.sources.filter((s) => kindsOf(r, s.id).length > 0)
/** The kinds whose points redeem. */
export const credited = (r: Rules): Kind[] => r.kinds.filter((k) => k.credit)
/** The line a post that earns points carries: the quoted words of the rules' disclosure, or all of it. */
export const disclosed = (r: Rules): string => r.disclosure.match(/"([^"]+)"/)?.[1] ?? r.disclosure
/** The bounty kind a set of GitHub labels names, if any: the rules' bounty-* kinds, by their labels. */
export function tier(r: Rules, labels: string[]): Kind | undefined {
const lower = labels.map((l) => l.toLowerCase())
return r.kinds.find((k) => k.id.startsWith('bounty-') && (r.github.labels[k.id] ?? []).some((l) => lower.includes(l.toLowerCase())))
}