The web half of Mischief Makers M3. A signed-in buyer picks a mark off the anonymous roster board and pays for a monster to find them; gogobee does the real work and hands back a verdict Pete files against the order. - mischief_orders: intent in, verdict out, idempotent on a guid that is the end-to-end key gogobee passes to DebitIdem and stamps on the contract - user_euro + mischief_tiers: advisory balance and the live price list, pushed on the roster tick so the storefront never hardcodes a number that can drift - OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire - roster board grows a 'send trouble' button, a tier picker, and a status panel Pete never touches money and never runs a game rule. It records what a buyer wants and what gogobee said happened.
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// The mischief storefront's half of the euro border.
|
|
//
|
|
// A buyer signs in, picks a mark off the anonymous roster board, and places a
|
|
// hit. Pete records only the *intent*: it never moves money and never runs a
|
|
// single game rule. gogobee's poll loop reads the pending orders, does the real
|
|
// work against its own ledger (debit, eligibility, open the contract), and hands
|
|
// back a verdict Pete files against the order. The guid is the idempotency key
|
|
// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so
|
|
// a verdict whose ack is lost on the wire can be retried without the buyer paying
|
|
// twice or the mark catching two hits for one order.
|
|
|
|
// MischiefOrder is one storefront order and its current standing.
|
|
type MischiefOrder struct {
|
|
GUID string `json:"guid"`
|
|
BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
|
BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server
|
|
TargetToken string `json:"target_token"`
|
|
TargetName string `json:"target_name"`
|
|
Tier string `json:"tier"`
|
|
Signed bool `json:"signed"`
|
|
Status string `json:"status"`
|
|
Detail string `json:"detail,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// Order states. These strings cross the wire to gogobee (it POSTs the verdict),
|
|
// so they are part of the contract — see the schema and gogobee_mischief_plan.md.
|
|
const (
|
|
MischiefPending = "pending" // placed; gogobee hasn't acted yet
|
|
MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract
|
|
MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all
|
|
MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark
|
|
)
|
|
|
|
// validMischiefVerdict is the set of terminal states gogobee is allowed to hand
|
|
// back. An unknown verdict is a contract mismatch, not something to file blindly.
|
|
func validMischiefVerdict(status string) bool {
|
|
switch status {
|
|
case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var ErrNoSuchOrder = errors.New("mischief: no such order")
|
|
|
|
// InsertMischiefOrder records a fresh, pending order and returns it with a new
|
|
// guid. Money and eligibility are gogobee's problem; all Pete asserts here is
|
|
// that the buyer is signed in and the form was well formed (checked by the
|
|
// caller). The guid is minted here so the buyer sees a stable reference the
|
|
// instant they submit, before gogobee has ever heard of it.
|
|
func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) {
|
|
guid, err := newGUID()
|
|
if err != nil {
|
|
return MischiefOrder{}, err
|
|
}
|
|
now := nowUnix()
|
|
sig := 0
|
|
if signed {
|
|
sig = 1
|
|
}
|
|
if _, err := Get().Exec(
|
|
`INSERT INTO mischief_orders
|
|
(guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now,
|
|
); err != nil {
|
|
return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err)
|
|
}
|
|
return MischiefOrder{
|
|
GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername,
|
|
TargetToken: targetToken, TargetName: targetName, Tier: tier,
|
|
Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// PendingMischiefOrders is what gogobee's poll loop reads: every order still
|
|
// waiting to be acted on. There is no claimed-but-stale window like escrow has,
|
|
// because a pending order carries no intermediate state — if gogobee dies partway
|
|
// through, the order simply stays pending and is offered again next poll, and the
|
|
// guid makes the replay a no-op.
|
|
func PendingMischiefOrders(limit int) ([]MischiefOrder, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
rows, err := Get().Query(
|
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM mischief_orders
|
|
WHERE status = ?
|
|
ORDER BY created_at
|
|
LIMIT ?`,
|
|
MischiefPending, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mischief: pending orders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanMischiefOrders(rows)
|
|
}
|
|
|
|
// ResolveMischiefOrder files gogobee's verdict against a pending order. It is
|
|
// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so
|
|
// a verdict that arrives twice is a no-op the second time. Only a pending order
|
|
// moves; an order already in a terminal state reports what it already decided,
|
|
// which is what stops a retried verdict from overwriting the first one.
|
|
func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) {
|
|
if !validMischiefVerdict(status) {
|
|
return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status)
|
|
}
|
|
now := nowUnix()
|
|
res, err := Get().Exec(
|
|
`UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ?
|
|
WHERE guid = ? AND status = ?`,
|
|
status, detail, now, guid, MischiefPending,
|
|
)
|
|
if err != nil {
|
|
return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// Either it doesn't exist or it's already terminal. Tell the caller which.
|
|
return MischiefOrderByGUID(guid)
|
|
}
|
|
return MischiefOrderByGUID(guid)
|
|
}
|
|
|
|
// MischiefOrderByGUID reads one order.
|
|
func MischiefOrderByGUID(guid string) (MischiefOrder, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM mischief_orders WHERE guid = ?`, guid,
|
|
)
|
|
if err != nil {
|
|
return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out, err := scanMischiefOrders(rows)
|
|
if err != nil {
|
|
return MischiefOrder{}, err
|
|
}
|
|
if len(out) == 0 {
|
|
return MischiefOrder{}, ErrNoSuchOrder
|
|
}
|
|
return out[0], nil
|
|
}
|
|
|
|
// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for
|
|
// the storefront status panel. Keyed on the OIDC subject so a username change
|
|
// doesn't strand a buyer's history.
|
|
func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
rows, err := Get().Query(
|
|
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM mischief_orders
|
|
WHERE buyer_sub = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`,
|
|
buyerSub, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mischief: orders by buyer: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanMischiefOrders(rows)
|
|
}
|
|
|
|
// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix
|
|
// cutoff. It backs the storefront's burst guard — the real economic caps (per
|
|
// day, per target, boss weekly) live on gogobee, this only blunts form-spam.
|
|
func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) {
|
|
var n int
|
|
err := Get().QueryRow(
|
|
`SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`,
|
|
buyerSub, since,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("mischief: count recent orders: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) {
|
|
var out []MischiefOrder
|
|
for rows.Next() {
|
|
var o MischiefOrder
|
|
var sig int
|
|
if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken,
|
|
&o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("mischief: scan order: %w", err)
|
|
}
|
|
o.Signed = sig != 0
|
|
out = append(out, o)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ---- user_euro: the buyer's own advisory balance -------------------------------
|
|
|
|
// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one
|
|
// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted
|
|
// out, deleted character) must drop out rather than keep a frozen number forever.
|
|
// Replace — never merge.
|
|
func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error {
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for _, b := range balances {
|
|
if b.Username == "" {
|
|
continue
|
|
}
|
|
if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// MischiefBalance is one buyer's advisory euro balance in the roster push.
|
|
type MischiefBalance struct {
|
|
Username string `json:"username"`
|
|
Euro float64 `json:"euro"`
|
|
}
|
|
|
|
// ---- mischief_tiers: the price catalog, pushed by gogobee -----------------------
|
|
|
|
// MischiefTier is one rung of the storefront's price list. gogobee is the sole
|
|
// authority on prices — it pushes the whole catalog on the roster tick so a fee
|
|
// retune reaches the storefront within a snapshot and Pete never hardcodes a
|
|
// number that can silently drift. Pete uses it only to render and to validate a
|
|
// submitted tier key; the real debit is always gogobee's, at its own price.
|
|
type MischiefTier struct {
|
|
Key string `json:"key"`
|
|
Display string `json:"display"`
|
|
Fee int `json:"fee"`
|
|
SignedFee int `json:"signed_fee"`
|
|
Blurb string `json:"blurb,omitempty"`
|
|
}
|
|
|
|
// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving
|
|
// the push order (grunt→boss) via an ordinal column. Replace, never merge — a
|
|
// tier gogobee dropped must disappear from the storefront.
|
|
func ReplaceMischiefTiers(tiers []MischiefTier) error {
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for i, t := range tiers {
|
|
if t.Key == "" {
|
|
continue
|
|
}
|
|
if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// MischiefTiers returns the catalog in push order. Empty (not an error) until
|
|
// gogobee has pushed one — the storefront treats that as "catalog not ready yet".
|
|
func MischiefTiers() ([]MischiefTier, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mischief: read tiers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []MischiefTier
|
|
for rows.Next() {
|
|
var t MischiefTier
|
|
if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil {
|
|
return nil, fmt.Errorf("mischief: scan tier: %w", err)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// MischiefTierByKey looks up one tier for validating a submitted order.
|
|
func MischiefTierByKey(key string) (MischiefTier, bool, error) {
|
|
tiers, err := MischiefTiers()
|
|
if err != nil {
|
|
return MischiefTier{}, false, err
|
|
}
|
|
for _, t := range tiers {
|
|
if t.Key == key {
|
|
return t, true, nil
|
|
}
|
|
}
|
|
return MischiefTier{}, false, nil
|
|
}
|
|
|
|
// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has
|
|
// never reported a balance for them (never played, opted out) — the storefront
|
|
// then shows tiers without an affordability hint rather than a misleading €0.
|
|
func UserEuro(username string) (float64, bool, error) {
|
|
var euro float64
|
|
err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("mischief: read user euro: %w", err)
|
|
}
|
|
return euro, true, nil
|
|
}
|