mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
70 lines
3.4 KiB
TypeScript
70 lines
3.4 KiB
TypeScript
/**
|
|
* The rewards rules, read once per build: what earns points, what each earns,
|
|
* which points redeem, the caps, the token milestones, the levels, the badges,
|
|
* the board's tabs and where a badge is claimed.
|
|
*
|
|
* GET https://api.hanzo.ai/v1/rewards/rules
|
|
*
|
|
* The cloud's rules table (hanzo-inc/cloud apps/rewards) is the one source:
|
|
* the cloud awards points by it and every page that says what earns what
|
|
* prints this snapshot of it, so the copy cannot drift from the awards. The
|
|
* pages also ask the endpoint when they open (lib/hanzo/rewards `useRules`),
|
|
* so a change the owner makes reaches a reader before the next build does.
|
|
*
|
|
* Two rules, the same as every sync in this directory:
|
|
*
|
|
* never fail the build the endpoint does not answer -> keep the snapshot.
|
|
* never take a stranger an answer that is not a rules table is refused, so
|
|
* an error page can never become what the site prints.
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const OUT = join(here, '..', 'lib', 'data', 'rewards-rules.json')
|
|
const URL = 'https://api.hanzo.ai/v1/rewards/rules'
|
|
|
|
const keep = (why: string): never => {
|
|
console.log(` rewards: ${why} — keeping the committed snapshot`)
|
|
process.exit(0)
|
|
}
|
|
|
|
/**
|
|
* A table is the cloud's Rules (apps/rewards rules.go): its kinds, sources,
|
|
* tiers, levels, badges and tabs non-empty lists, its caps a list, a redeem rate,
|
|
* a chain, and no payouts. An answer of an earlier shape is refused, so the
|
|
* pages never print fields they no longer read.
|
|
*/
|
|
const table = (raw: unknown): raw is { kinds: { id: string; source: string; points: number }[] } => {
|
|
if (!raw || typeof raw !== 'object') return false
|
|
const r = raw as Record<string, unknown>
|
|
const list = (k: string) => Array.isArray(r[k]) && (r[k] as unknown[]).length > 0
|
|
if (!['kinds', 'sources', 'tiers', 'levels', 'badges', 'tabs'].every(list) || !Array.isArray(r.caps)) return false
|
|
const redeem = r.redeem as Record<string, unknown> | undefined
|
|
if (typeof redeem?.pointsPerDollar !== 'number' || !r.chain || typeof r.chain !== 'object' || r.payouts !== false || typeof r.disclosure !== 'string') return false
|
|
return (r.kinds as unknown[]).every((k) => {
|
|
const o = k as Record<string, unknown>
|
|
return typeof o.id === 'string' && typeof o.source === 'string' && typeof o.points === 'number' && typeof o.live === 'boolean' && typeof o.credit === 'boolean'
|
|
})
|
|
}
|
|
|
|
const res = await fetch(URL, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) }).catch((err: Error) => keep(err.message))
|
|
if (!res.ok) keep(`GET ${URL} answered ${res.status}`)
|
|
// Kept as the cloud serves it, byte for byte, so the snapshot is the answer and not a re-rendering of it.
|
|
const next = await res.text().catch(() => keep(`GET ${URL} answered no body`))
|
|
let body: unknown
|
|
try {
|
|
body = JSON.parse(next)
|
|
} catch {
|
|
keep(`GET ${URL} answered something that is not JSON`)
|
|
}
|
|
const got = table(body) ? body : keep(`GET ${URL} answered something that is not a rules table`)
|
|
|
|
const now = readFileSync(OUT, 'utf8')
|
|
if (next === now) console.log(` rewards: ${got.kinds.length} kinds, unchanged`)
|
|
else {
|
|
writeFileSync(OUT, next)
|
|
console.log(` rewards: ${got.kinds.length} kinds written to lib/data/rewards-rules.json`)
|
|
}
|