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
10 changes: 6 additions & 4 deletions cloudflare-worker/src/routes/admin_credit_rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*
* GET / every reward rule and the monthly bounty cap
* PUT /:key change one rule's credits, value or on/off
* GET /grants rule-based grants, newest first (?user_id=)
* GET /grants rule-based grants and manual adjustments (D608,
* `manual: true`), newest first (?user_id=)
* POST /grants/:id/revoke take one back
* POST /lab-backfill pay the current Lab cohort once for milestones
* completed before credits launched (D541,
Expand All @@ -20,7 +21,7 @@ import { Hono } from 'hono';
import type { Env } from '../types';
import { requireSuperAdmin } from '../auth';
import { logAdminAction } from '../services/adminAudit';
import { CAP_RULE_KEY, isBountyRule, revokeGrant } from '../services/creditGrants';
import { CAP_RULE_KEY, MANUAL_RULE_KEY, isBountyRule, revokeGrant } from '../services/creditGrants';
import { backfillLabCredits } from '../services/labMilestoneCredits';
import { mapError } from './_t13t14t15_helpers';
import { refuse } from '../util/refusal';
Expand Down Expand Up @@ -84,7 +85,7 @@ r.get('/grants', async (c) => {
LEFT JOIN users u ON u.id = g.user_id
LEFT JOIN perk_credit_ledger r
ON r.user_id = g.user_id AND r.kind = 'admin_adjust' AND r.source_ref = 'revoke:' || g.id
WHERE g.kind = 'grant' AND g.rule_key IS NOT NULL AND g.user_id = ?
WHERE ((g.kind = 'grant' AND g.rule_key IS NOT NULL) OR (g.kind = 'admin_adjust' AND g.rule_key = 'manual' AND substr(g.source_ref, 1, 7) <> 'revoke:')) AND g.user_id = ?
ORDER BY g.created_at DESC, g.id DESC LIMIT 200`,
).bind(userId)
: c.env.DB.prepare(
Expand All @@ -94,13 +95,14 @@ r.get('/grants', async (c) => {
LEFT JOIN users u ON u.id = g.user_id
LEFT JOIN perk_credit_ledger r
ON r.user_id = g.user_id AND r.kind = 'admin_adjust' AND r.source_ref = 'revoke:' || g.id
WHERE g.kind = 'grant' AND g.rule_key IS NOT NULL
WHERE ((g.kind = 'grant' AND g.rule_key IS NOT NULL) OR (g.kind = 'admin_adjust' AND g.rule_key = 'manual' AND substr(g.source_ref, 1, 7) <> 'revoke:'))
ORDER BY g.created_at DESC, g.id DESC LIMIT 200`,
)
).all<any>();
return c.json({
grants: (rows.results || []).map((g: any) => ({
id: g.id, user_id: g.user_id, email: g.email ?? null, credits: Number(g.credits), rule_key: g.rule_key,
manual: g.rule_key === MANUAL_RULE_KEY,
source_ref: g.source_ref, note: g.note, created_at: g.created_at,
revoked: g.revoke_id !== null && g.revoke_id !== undefined,
revoke_reason: g.revoke_reason ?? null, revoked_at: g.revoked_at ?? null,
Expand Down
36 changes: 16 additions & 20 deletions cloudflare-worker/src/routes/perks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* ADMIN
* GET /admin/queue everything awaiting review
* POST /admin/:uid/review approve / reject / pause
* POST /admin/credits grant credits to a user
* POST /admin/credits a Super Admin's manual grant or deduction (D608)
*
* THE THREE PRICE KINDS, and why the credit one is the only interesting one:
*
Expand Down Expand Up @@ -59,11 +59,11 @@
import { Hono } from 'hono';
import { activeCompanyFor } from '../middleware/activeCompany';
import type { Env } from '../types';
import { requireAuth, requireAdmin, requireRole } from '../auth';
import { requireAuth, requireAdmin, requireRole, requireSuperAdmin } from '../auth';
import { mapError, newUid, nowIso, todayIso } from './_t13t14t15_helpers';
import { userMeetsTier, type Tier } from '../middleware/requireTier';
import { refuse } from '../util/refusal';
import { ruleReason } from '../services/creditGrants';
import { manualAdjust, ruleReason } from '../services/creditGrants';

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

Expand Down Expand Up @@ -776,27 +776,23 @@ r.get('/admin/queue', async (c) => {
} catch (e) { return mapError(c, e); }
});

// D608 (#1308): a manual grant or deduction is the Super Admin's, with a typed
// reason, and goes through the grant service like every other grant: one
// ledger line, idempotent on its source, an audit row and an inbox notice.
r.post('/admin/credits', async (c) => {
try {
const admin = await requireAdmin(c);
const admin = await requireSuperAdmin(c);
const b = await c.req.json().catch(() => ({} as any));
const userId = intOrNull(b?.user_id);
const delta = intOrNull(b?.delta);
if (!userId || !delta) return c.json({ error: 'user_id and a non-zero delta are required' }, 400);
const target = await c.env.DB.prepare('SELECT id FROM users WHERE id = ?')
.bind(userId).first<{ id: number }>();
if (!target) return c.json({ error: 'not_found' }, 404);
// A grant must not take a balance negative — the ledger would then owe
// credits nobody can spend.
if (delta < 0 && (await balanceOf(c.env, userId)) + delta < 0) {
return c.json({ error: 'that would take the balance below zero' }, 400);
const out = await manualAdjust(c.env, {
userId: b?.user_id, delta: b?.delta, reason: b?.reason, note: b?.note, sourceRef: b?.source_ref,
actor: { id: admin.id, email: admin.email },
});
if (out.status === 'refused') {
const status = out.code === 'user_not_found' ? 404 : out.code === 'already_recorded' ? 409 : 400;
const extra = out.ledger_id !== undefined ? { ledger_id: out.ledger_id } : out.balance !== undefined ? { balance: out.balance } : {};
return refuse(c, status, { code: out.code, message: out.message, extra });
}
const ref = str(b?.source_ref, 120) || `admin:${admin.id}:${nowIso()}`;
await c.env.DB.prepare(
`INSERT INTO perk_credit_ledger (user_id, delta, kind, source_ref, note, created_at)
VALUES (?,?,?,?,?,?)`,
).bind(userId, delta, delta > 0 ? 'grant' : 'admin_adjust', ref, str(b?.note, 500) || null, nowIso()).run();
return c.json({ ok: true, balance: await balanceOf(c.env, userId) });
return c.json({ ok: true, ...out });
} catch (e) { return mapError(c, e); }
});

Expand Down
89 changes: 89 additions & 0 deletions cloudflare-worker/src/services/creditGrants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,92 @@ export async function revokeGrant(env: Env, args: {
{ grant_ledger_id: grant.id, ledger_id: ledgerId, credits });
return { status: 'revoked', ledger_id: ledgerId, credits, notified };
}

/** D608: the marker `rule_key` a Super Admin's manual adjustment carries, so the grants list can show it. */
export const MANUAL_RULE_KEY = 'manual';
/** The size of one manual adjustment, either way; the same bound a rule's credits take (`admin_credit_rules.ts`). */
export const MANUAL_DELTA_MAX = 100_000;
/** The typed reason's floor, the one every Super Admin credit act shares (`CREDIT_REASON_MIN`). */
export const MANUAL_REASON_MIN = 10;

export type ManualRefusalCode =
| 'invalid_user' | 'invalid_delta' | 'reason_too_short' | 'note_too_long' | 'invalid_source_ref'
| 'user_not_found' | 'already_recorded' | 'would_overdraw';

export type ManualResult =
| { status: 'recorded'; ledger_id: number; delta: number; source_ref: string; balance: number; notified: boolean }
| { status: 'refused'; code: ManualRefusalCode; message: string; ledger_id?: number; balance?: number };

const balanceSql = 'SELECT COALESCE(SUM(delta), 0) AS b FROM perk_credit_ledger WHERE user_id = ?';

/**
* D608 — a Super Admin's manual grant or deduction. The route checks the
* caller is the Super Admin; everything else is here:
* - `delta` a non-zero whole number, at most MANUAL_DELTA_MAX either way;
* - a typed `reason` of at least MANUAL_REASON_MIN characters, in the audit row;
* - a `note` the member sees, at most 500 characters, refused rather than cut;
* - IDEMPOTENT ON `source_ref`: a ref this user's ledger already holds, of any
* kind, is `already_recorded` naming that row, never a raw index error;
* - a deduction that would take the balance below zero is `would_overdraw`.
* The balance test and the duplicate test are INSIDE the insert, so two
* adjustments racing cannot both pass them.
* A grant is a `grant` row, a deduction an `admin_adjust` row; both carry
* `rule_key = 'manual'`. It is not a bounty, so it never counts toward the cap.
*/
export async function manualAdjust(env: Env, args: {
userId: unknown; delta: unknown; reason: unknown; note?: unknown; sourceRef?: unknown;
actor: { id: number; email: string };
}): Promise<ManualResult> {
const refused = (code: ManualRefusalCode, message: string, extra: { ledger_id?: number; balance?: number } = {}): ManualResult =>
({ status: 'refused', code, message, ...extra });
const userId = Number(args.userId);
if (!Number.isInteger(userId) || userId <= 0) return refused('invalid_user', 'Name the account by its user id.');
const delta = typeof args.delta === 'number' ? args.delta : NaN;
if (!Number.isInteger(delta) || delta === 0 || Math.abs(delta) > MANUAL_DELTA_MAX) {
return refused('invalid_delta', `The change must be a whole number of credits, not zero, at most ${MANUAL_DELTA_MAX.toLocaleString('en-US')} either way.`);
}
const reason = clean(args.reason, NOTE_MAX);
if (reason.length < MANUAL_REASON_MIN) {
return refused('reason_too_short', `Say why — at least ${MANUAL_REASON_MIN} characters. It is recorded beside the change.`);
}
const rawNote = String(args.note ?? '').trim();
if (rawNote.length > NOTE_MAX) return refused('note_too_long', `The note the member sees is at most ${NOTE_MAX} characters.`);
const rawRef = String(args.sourceRef ?? '').trim();
if (rawRef.length > SOURCE_MAX) return refused('invalid_source_ref', `A source reference is at most ${SOURCE_MAX} characters.`);
const sourceRef = rawRef || `admin:${args.actor.id}:${new Date().toISOString()}`;

const user = await env.DB.prepare('SELECT id FROM users WHERE id = ?').bind(userId).first<{ id: number }>();
if (!user) return refused('user_not_found', 'No such account.');

const kind = delta > 0 ? 'grant' : 'admin_adjust';
const note = rawNote || (delta > 0 ? 'Credits added by the Axal team' : 'Credits deducted by the Axal team');
const ins = await env.DB.prepare(
`INSERT OR IGNORE INTO perk_credit_ledger (user_id, delta, kind, source_ref, note, created_at, rule_key)
SELECT ?, ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM perk_credit_ledger d WHERE d.user_id = ? AND d.source_ref = ?)
AND (SELECT COALESCE(SUM(b.delta), 0) FROM perk_credit_ledger b WHERE b.user_id = ?) + ? >= 0`,
).bind(userId, delta, kind, sourceRef, note, new Date().toISOString(), MANUAL_RULE_KEY,
userId, sourceRef, userId, delta).run();

if (Number(ins?.meta?.changes ?? 0) !== 1) {
const prior = await env.DB.prepare(
'SELECT id FROM perk_credit_ledger WHERE user_id = ? AND source_ref = ? ORDER BY id LIMIT 1',
).bind(userId, sourceRef).first<{ id: number }>();
if (prior) {
return refused('already_recorded', `This account's ledger already holds a line with the source ${sourceRef}, so nothing was recorded.`, { ledger_id: Number(prior.id) });
}
const bal = Number((await env.DB.prepare(balanceSql).bind(userId).first<{ b: number }>())?.b) || 0;
return refused('would_overdraw', `The account holds ${plural(bal)}; deducting ${plural(-delta)} would take it below zero, so nothing was recorded.`, { balance: bal });
}

const ledgerId = Number(ins.meta.last_row_id);
await logAdminAction(env, args.actor.id, args.actor.email, 'credit_manual_adjust', {
target_user_id: userId, delta, ledger_id: ledgerId, source_ref: sourceRef, reason, note,
});
const notified = await tell(env, userId, 'credit_manual_adjust',
delta > 0 ? `${plural(delta)} added to your account` : `${plural(-delta)} deducted from your account`,
`${note}. Credits have no cash value and cannot be transferred.`,
{ ledger_id: ledgerId, delta });
const balance = Number((await env.DB.prepare(balanceSql).bind(userId).first<{ b: number }>())?.b) || 0;
return { status: 'recorded', ledger_id: ledgerId, delta, source_ref: sourceRef, balance, notified };
}
Loading
Loading