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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- 409 — D572 part 3b (#1183): where a payout is sent, and the leaderboard's
-- earlier standings.
--
-- WHERE A PAYOUT IS SENT (the owner's Q1 = A). A partner names the
-- destination only as they would recognise it: a label ("Main business
-- account", "Ledger wallet") and the last 4 characters of the account or
-- wallet. The full details stay with the processor, out of band, so nothing
-- that could move money is stored in D1. Both are optional on a request and
-- set together; a payout requested before this reads "Not recorded".
-- destination_label at most 60 characters, checked by the route
-- destination_last4 exactly 4 letters or digits, checked by the route
--
-- THE LEADERBOARD'S EARLIER STANDINGS. `/partnernet/leaderboard/board` ranks
-- by a metric over a period, and its "change" is the move against a stored
-- earlier standing. `partner_leaderboard_snapshots` is one row per day,
-- metric, period and member, written once a day by the cron
-- (`writeLeaderboardSnapshots`). Re-running a day writes nothing new: the
-- primary key ignores a second write. Only members with a value above zero
-- are ranked, so a member absent from a snapshot was not on the board then.
-- snapshot_date YYYY-MM-DD, UTC
-- metric score | referrals | commissions
-- period_key 'all', or a quarter such as '2026-Q4'
-- value the measure on that day
-- rank 1-based; equal values share a rank
--
-- No BEGIN/COMMIT: D1 rejects transaction statements in a migration (#26).

ALTER TABLE payouts ADD COLUMN destination_label TEXT;
ALTER TABLE payouts ADD COLUMN destination_last4 TEXT;

CREATE TABLE IF NOT EXISTS partner_leaderboard_snapshots (
snapshot_date TEXT NOT NULL,
metric TEXT NOT NULL CHECK (metric IN ('score', 'referrals', 'commissions')),
period_key TEXT NOT NULL,
user_id INTEGER NOT NULL,
value INTEGER NOT NULL,
rank INTEGER NOT NULL CHECK (rank >= 1),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (snapshot_date, metric, period_key, user_id)
);
CREATE INDEX IF NOT EXISTS idx_partner_lb_snap_lookup
ON partner_leaderboard_snapshots(metric, period_key, snapshot_date);
12 changes: 12 additions & 0 deletions cloudflare-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,18 @@ export default {
console.error('[cron] learning video sweep failed', e);
}
}
// D572 part 3b — the partner leaderboard's daily standings, so the
// board's "change" has an earlier standing to compare with. Every
// tier: a branch's partners are its own. Idempotent per day.
if (now.getUTCHours() === 5 && now.getUTCMinutes() === 25) {
try {
const { writeLeaderboardSnapshots } = await import('./services/partnerLeaderboard');
const r = await writeLeaderboardSnapshots(env, now.getTime());
if (r.written || r.failed.length) console.info(`[cron] leaderboard snapshots written=${r.written} failed=${r.failed.length}`);
} catch (e) {
console.error('[cron] leaderboard snapshots failed', e);
}
}
// D484 — publish approved articles whose scheduled time has passed.
// Every minute, on every tier: a branch's articles are its own, and an
// article with no `publish_at` is never touched.
Expand Down
49 changes: 44 additions & 5 deletions cloudflare-worker/src/routes/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ async function ensureSchema(env: Env) {
failure_reason TEXT,
processed_by INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
completed_at TIMESTAMP,
-- Migration 409 (D572 part 3b), named here so a table this creates
-- matches one the migrations built.
destination_label TEXT,
destination_last4 TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_payouts_user ON payouts(user_id)`,
`CREATE TABLE IF NOT EXISTS commission_rules (
Expand Down Expand Up @@ -701,6 +705,33 @@ async function withCommissionIds(env: Env, payouts: any[]): Promise<any[]> {
return payouts.map((p) => ({ ...p, commission_ids: byPayout.get(p.id) ?? (p.uid ? [] : null) }));
}

/**
* D572 part 3b (the owner's Q1 = A) — where a payout is sent, as the partner
* would recognise it: a label and the last 4 characters of the account or
* wallet. The full details stay with the processor, out of band, so nothing
* that could move money is stored. Both are optional and go together: one
* without the other is refused, because "···· 4821" with no name, or a name
* with no account, cannot be told apart from another destination.
*/
export function readPayoutDestination(label: unknown, last4: unknown):
| { label: string | null; last4: string | null }
| { error: { code: string; message: string } } {
const l = label == null ? '' : String(label).trim();
const f = last4 == null ? '' : String(last4).trim();
if (!l && !f) return { label: null, last4: null };
if (!l || !f) {
return { error: { code: 'destination_incomplete', message: 'Name the destination and give its last 4 characters, or leave both out.' } };
}
// No control characters: the label is printed on the admin's queue.
if (l.length > 60 || /[\u0000-\u001f\u007f]/.test(l)) {
return { error: { code: 'invalid_destination_label', message: 'A destination label is at most 60 characters of plain text.' } };
}
if (!/^[A-Za-z0-9]{4}$/.test(f)) {
return { error: { code: 'invalid_destination_last4', message: 'Give exactly the last 4 letters or digits of the account or wallet, and nothing more.' } };
}
return { label: l, last4: f.toUpperCase() };
}

network.get('/payouts/me', async (c) => {
const user = await requireAuth(c);
await ensureSchema(c.env);
Expand All @@ -714,11 +745,15 @@ network.get('/payouts/me', async (c) => {
network.post('/payout/request', async (c) => {
const user = await requireAuth(c);
await ensureSchema(c.env);
const { amount_cents, payout_method, payout_details, currency } = await c.req.json().catch(() => ({}));
const {
amount_cents, payout_method, payout_details, currency, destination_label, destination_last4,
} = await c.req.json().catch(() => ({}));

const amt = parseInt(amount_cents);
if (!amt || amt < 1000) return c.json({ error: 'Minimum payout is $10.00' }, 400);
if (!['stripe', 'wire', 'crypto'].includes(payout_method)) return c.json({ error: 'Invalid payout_method (stripe|wire|crypto)' }, 400);
const destination = readPayoutDestination(destination_label, destination_last4);
if ('error' in destination) return refuse(c, 400, destination.error);

const sql = getSQL(c.env);
try {
Expand Down Expand Up @@ -772,8 +807,9 @@ network.post('/payout/request', async (c) => {
// ran unconditionally, so a request that lost a race on one commission
// still marked the rest paid with no payout behind them.
const ph = usedIds.map(() => '?').join(',');
const insertSql = `INSERT INTO payouts (uid, user_id, amount_cents, currency, status, payout_method, payout_details)
SELECT ?, ?, ?, ?, 'requested', ?, ?
const insertSql = `INSERT INTO payouts (uid, user_id, amount_cents, currency, status, payout_method, payout_details,
destination_label, destination_last4)
SELECT ?, ?, ?, ?, 'requested', ?, ?, ?, ?
WHERE (SELECT COUNT(*) FROM commissions WHERE id IN (${ph}) AND user_id = ? AND status = 'accrued' AND currency = ?) = ?
RETURNING *`;
const linkSql = `INSERT INTO payout_commissions (payout_id, commission_id)
Expand All @@ -784,7 +820,10 @@ network.post('/payout/request', async (c) => {
AND EXISTS (SELECT 1 FROM payouts WHERE uid = ?)`;

const batchResult = await c.env.DB.batch([
c.env.DB.prepare(insertSql).bind(uid, user.id, amt, cur, payout_method, detailsJson, ...usedIds, user.id, cur, usedIds.length),
c.env.DB.prepare(insertSql).bind(
uid, user.id, amt, cur, payout_method, detailsJson, destination.label, destination.last4,
...usedIds, user.id, cur, usedIds.length,
),
c.env.DB.prepare(linkSql).bind(uid, ...usedIds),
c.env.DB.prepare(claimSql).bind(...usedIds, user.id, uid),
]);
Expand Down
85 changes: 85 additions & 0 deletions cloudflare-worker/src/routes/partnernet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { requireAuth } from '../auth';
import { newUid } from './_t13t14t15_helpers';
import { notify } from '../services/notify';
import { logAdminAction } from '../services/adminAudit';
import {
BOARD_LIMIT, CHANGE_DAYS, LEADERBOARD_METRICS, LEADERBOARD_PERIODS, PERIODS_FOR,
changeOf, comparisonFor, liveRanking, periodKey,
type LeaderboardMetric, type LeaderboardPeriod,
} from '../services/partnerLeaderboard';

const partnernet = new Hono<{ Bindings: Env }>();

Expand Down Expand Up @@ -1278,6 +1283,86 @@ partnernet.get('/leaderboard/public', async (c) => {
})));
});

/**
* D572 part 3b (#1183) — the board by metric and period, with the change
* against a stored earlier standing (`services/partnerLeaderboard.ts` says
* what each metric counts and how the change is read).
*
* GET /leaderboard/board?metric=score|referrals|commissions&period=all|quarter
*
* Any signed-in member. Other members appear by rank, role and value only:
* no id, name or email leaves this route. The viewer's own standing is given
* wherever it falls, not only inside the listed 25. A member hidden from the
* directory is not ranked, and is told so when they read it.
*/
partnernet.get('/leaderboard/board', async (c) => {
const user = await requireAuth(c);
const metric = String(c.req.query('metric') || 'score') as LeaderboardMetric;
const period = String(c.req.query('period') || 'all') as LeaderboardPeriod;
if (!(LEADERBOARD_METRICS as readonly string[]).includes(metric)) {
return refuse(c, 400, { code: 'bad_metric', message: `metric is one of ${LEADERBOARD_METRICS.join(', ')}.` });
}
if (!(LEADERBOARD_PERIODS as readonly string[]).includes(period)) {
return refuse(c, 400, { code: 'bad_period', message: `period is one of ${LEADERBOARD_PERIODS.join(', ')}.` });
}
if (!PERIODS_FOR[metric].includes(period)) {
return refuse(c, 400, {
code: 'period_not_for_metric',
message: 'The network score is a standing, not a tally, so it has no period: it is ranked as it stands now.',
extra: { periods: PERIODS_FOR[metric] },
});
}
const nowMs = Date.now();
let ranked;
try {
ranked = await liveRanking(c.env, metric, period, nowMs);
} catch (e) {
return refuse(c, 503, {
code: 'board_unreadable',
message: 'The leaderboard could not be read, so this is not a claim that nobody is ranked.',
raw: (e as Error)?.message,
});
}
const cmp = await comparisonFor(c.env, metric, period, nowMs);
const row = (r: (typeof ranked)[number]) => ({
rank: r.rank,
role: r.role,
value: r.value,
change: changeOf(r.user_id, r.rank, cmp),
is_viewer: r.user_id === user.id,
});
const mine = ranked.find((r) => r.user_id === user.id);
let viewer: Record<string, unknown>;
if (mine) {
viewer = { ranked: true, ...row(mine) };
} else {
let hidden = false;
try {
const s = await c.env.DB.prepare('SELECT show_in_directory FROM user_settings WHERE user_id = ?')
.bind(user.id).first<{ show_in_directory: number | null }>();
hidden = s?.show_in_directory === 0;
} catch { /* no settings table: visible, as everywhere else */ }
viewer = {
ranked: false,
reason: hidden
? 'You chose not to appear in the directory, so you are not ranked.'
: 'You have nothing counted for this metric and period, so you are not ranked.',
};
}
return c.json({
metric,
period,
period_key: periodKey(period, nowMs),
as_of: new Date(nowMs).toISOString(),
total_ranked: ranked.length,
rows: ranked.slice(0, BOARD_LIMIT).map(row),
viewer,
compare: cmp.available
? { available: true, date: cmp.date, days: CHANGE_DAYS }
: { available: false, reason: cmp.reason, ...(cmp.unreadable ? { unreadable: true } : {}) },
});
});

/**
* U8 (D493) — the one-time notice for relationships that existed before 368.
* The owner chose to keep them as accepted and tell their subjects. The
Expand Down
Loading
Loading