Skip to content
quarry Artifact [17232b2703]
Login

Artifact [17232b2703]

Artifact 17232b270318d50f6fafd6d8a0eb874a2d63e66271874af28d2c27735233b982:


// inbox.go is RFC 0011 §6 at the ops seam: an inbox a person can page
// and CLEAR (`inbox.list`, `inbox.mark`), the channel preferences that
// decide what reaches them (`me.prefs`, `me.prefs.set`), and the three
// calls a browser needs to receive a push with no tab open
// (`me.push.key`, `me.push.subscribe`, `me.push.unsubscribe`).
//
// The division of labour is RFC 0011's: notify owns notifications,
// quarry owns the attention stream and the forge-local switches nobody
// else can know about (a muted repository, a toast preference, which
// browsers a person turned push on in). This file merges the two into
// the one list a person reads, and splits a mark back out to both.
//
// Like every "me" op, these carry no Caps: the router resolves a
// capability set against the repository named in the path, and these
// name none. Auth Token is the real gate.
package api

import (
	"context"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
)

// InboxMessage is one notify notification in this package's own terms
// — stated here rather than imported, for AttentionEvent's reason: the
// ops layer names what it needs and the composition root owns the
// transport.
type InboxMessage struct {
	ID string
	// Workflow is what sent it, and the only thing on a message that
	// says WHY it arrived. `inbox.list` turns it into a reason.
	Workflow string
	Subject  string
	Body     string
	Link     string
	At       string
	Read     bool
	Seen     bool
}

// PushDevice is one browser a person turned push on in. quarry holds no
// endpoint (see oltp.PushDevice) — this is the list a preferences page
// shows so somebody can turn one of them off.
type PushDevice struct {
	EndpointHash string `json:"endpoint_hash"`
	UA           string `json:"ua,omitempty"`
	At           string `json:"at,omitempty"`
}

// InboxPlane is the notify companion as the person-addressed ops need
// it. nil is a forge with no notify: `inbox.count` falls back to the
// local attention tail, `inbox.list` shows only local events, and the
// push and preference ops answer that this forge has no such thing
// rather than pretending a setting took.
type InboxPlane interface {
	// Unread counts the account's unread in-app notifications.
	Unread(ctx context.Context, account string) (int, error)
	// List is one page, newest first: the messages, and the cursor for
	// the next page ("" when this was the last).
	List(ctx context.Context, account, before string, limit int) ([]InboxMessage, string, error)
	// Mark moves messages to "read" or "archived" — either the named
	// ids or, with all, the account's whole inbox.
	Mark(ctx context.Context, account string, ids []string, all bool, as string) error
	// Preferences answers workflow -> channel -> enabled. An absent
	// workflow or channel is ENABLED, which is notify's own rule.
	Preferences(ctx context.Context, account string) (map[string]map[string]bool, error)
	// SetPreferences writes the whole matrix.
	SetPreferences(ctx context.Context, account string, prefs map[string]map[string]bool) error
	// PushKey is the VAPID public key a browser subscribes with.
	PushKey(ctx context.Context) (string, error)
	// PushSubscribe stores one browser's subscription.
	PushSubscribe(ctx context.Context, account, endpoint, p256dh, auth string) error
	// PushUnsubscribe drops one endpoint's subscription.
	PushUnsubscribe(ctx context.Context, account, endpoint string) error
}

// AccountPrefStore is the forge-local half of a person's preferences:
// the switches notify has no opinion about (toasts, desktop
// notifications) and the per-repository mute quarry implements by not
// triggering at all. nil means this forge keeps no preferences.
type AccountPrefStore interface {
	AccountPref(account, key string) string
	SetAccountPref(account, key, value string) error
}

// PushDeviceStore remembers which browsers an account enabled push in.
// nil means the preferences page lists no devices — the subscriptions
// still work, since they live in notify.
type PushDeviceStore interface {
	AddPushDevice(account, endpoint, ua string) error
	DeletePushDevice(account, endpoint string) error
	PushDevices(account string) ([]PushDevice, error)
}

// prefMute is the account_pref key holding the muted repositories, one
// space-separated list rather than a row per repository: the whole set
// is read on every notify trigger, and one key lookup beats a scan.
const prefMute = "mute"

// prefToasts and prefDesktop are the two forge-local switches. Both
// default ON when unset, so the row exists only for somebody who turned
// something off — "off is the absence of a preference" is already how
// SetAccountPref stores an empty value.
const (
	prefToasts  = "toasts"
	prefDesktop = "notify_desktop"
)

// prefFamilies maps RFC 0011 §6's four preference FAMILIES to the
// workflows that carry them. A family is what a person recognizes ("a
// reply") — the workflow name is quarry's own plumbing, and a
// preferences page must not make somebody learn it.
var prefFamilies = []struct{ family, workflow string }{
	{"mention", "quarry-mention"},
	{"reply", "quarry-forum-reply"},
	{"landing", "quarry-landing"},
	{"activity", "quarry-activity"},
}

// prefChannels maps quarry's channel names to notify's. Two spellings
// exist because "in_app" is notify's wire token and an HTML form field
// named with an underscore reads as somebody else's vocabulary leaking
// into ours.
var prefChannels = map[string]string{"inapp": "in_app", "push": "push", "email": "email"}

// prefChannelOrder is the matrix's column order — a map would render
// the page differently on every load.
var prefChannelOrder = []string{"inapp", "push", "email"}

// workflowReason turns a workflow name into the one-word reason an
// inbox row shows. Anything unmapped falls back to the name minus the
// "quarry-" prefix, so a workflow added later still reads sensibly
// instead of rendering blank.
var workflowReason = map[string]string{
	"quarry-mention":     "mention",
	"quarry-forum-reply": "reply",
	"quarry-landing":     "landing",
	"quarry-activity":    "activity",
	"quarry-error":       "error",
	"quarry-slow":        "slow",
}

func reasonOf(workflow string) string {
	if r, ok := workflowReason[workflow]; ok {
		return r
	}
	return strings.TrimPrefix(workflow, "quarry-")
}

// localIDPrefix marks an inbox row that came from quarry's own
// attention stream rather than notify. `inbox.mark` splits its ids back
// apart on it, so one list and one mark form serve both sources without
// the page having to keep them in separate buckets.
const localIDPrefix = "local:"

func localID(seq uint64) string { return localIDPrefix + strconv.FormatUint(seq, 10) }

// splitInboxIDs separates notify message ids from local attention
// sequences. An id that claims to be local but is not a number is
// dropped rather than forwarded to notify: it is quarry's own spelling
// gone wrong, and notify would 404 on it.
func splitInboxIDs(ids []string) (notify []string, seqs []uint64) {
	for _, id := range ids {
		if rest, ok := strings.CutPrefix(id, localIDPrefix); ok {
			if n, err := strconv.ParseUint(rest, 10, 64); err == nil {
				seqs = append(seqs, n)
			}
			continue
		}
		if id != "" {
			notify = append(notify, id)
		}
	}
	return notify, seqs
}

// mutedRepos reads an account's muted set.
func (d Deps) mutedRepos(account string) map[string]bool {
	if d.Prefs == nil || account == "" {
		return nil
	}
	fields := strings.Fields(d.Prefs.AccountPref(account, prefMute))
	if len(fields) == 0 {
		return nil
	}
	out := make(map[string]bool, len(fields))
	for _, f := range fields {
		out[f] = true
	}
	return out
}

// muted reports whether account has silenced notify for repo. The mute
// is deliberately NOT applied to the attention stream: a person who
// muted a repository still finds its events in their own inbox when
// they go looking — what they turned off is being TOLD (RFC 0011 §6,
// "a per-repo mute that quarry implements by not triggering").
func (d Deps) muted(account, repo string) bool {
	if account == "" || repo == "" {
		return false
	}
	return d.mutedRepos(account)[repo]
}

// prefBool reads a forge-local switch that defaults ON.
func (d Deps) prefBool(account, key string) bool {
	if d.Prefs == nil {
		return true
	}
	v := d.Prefs.AccountPref(account, key)
	return v == "" || (v != "off" && v != "false" && v != "0")
}

type inboxRow struct {
	// ID addresses the row in `inbox.mark`: a notify message id, or
	// "local:<seq>" for one of quarry's own attention events.
	ID string `json:"id"`
	// Source is "notify" or "local" — which half of RFC 0011 the row
	// came from, so a page can style a row it can only mark locally.
	Source string `json:"source"`
	// Reason is why it arrived: mention, reply, landing, activity.
	Reason  string `json:"reason,omitempty"`
	Repo    string `json:"repo,omitempty"`
	From    string `json:"from,omitempty"`
	Summary string `json:"summary,omitempty"`
	Link    string `json:"link,omitempty"`
	At      string `json:"at"`
	Read    bool   `json:"read"`
	// Seq is the attention sequence for a local row, 0 for a notify one.
	Seq uint64 `json:"seq,omitempty"`
}

type inboxListIn struct {
	// Before is notify's cursor from a previous page's `next`.
	Before string `json:"before" validate:"max=128"`
	// Limit bounds the page; 0 takes the op's default.
	Limit int `json:"limit" validate:"min=0,max=200"`
}

type inboxListOut struct {
	Rows []inboxRow `json:"rows"`
	// Next is the cursor for the following page, "" at the end.
	Next string `json:"next,omitempty"`
	// Read is the local attention read pointer: every local row above
	// it is unread, which is the same number the badge is measured from.
	Read uint64 `json:"read"`
}

type inboxMarkIn struct {
	// IDs are the rows to mark; repeat the key (or send a JSON array).
	IDs []string `json:"ids" validate:"max=500"`
	// All marks the whole inbox instead, ignoring ids.
	All bool `json:"all"`
	// As is the state to move to: read, or done (archived — off the
	// list entirely).
	As string `json:"as" validate:"oneof=read|done,max=8"`
}

type inboxMarkOut struct {
	OK bool `json:"ok"`
	// Seq is the local attention pointer after the mark.
	Seq uint64 `json:"seq,omitempty"`
}

type mePrefsIn struct{}

type mePrefsOut struct {
	// Families is family -> channel -> enabled, over RFC 0011 §6's
	// {mention, reply, landing, activity} x {inapp, push, email}.
	Families map[string]map[string]bool `json:"families"`
	// Channels is the column order the matrix renders in.
	Channels []string `json:"channels"`
	// Toasts and Desktop are the two forge-local switches, both on
	// unless somebody turned them off.
	Toasts  bool `json:"toasts"`
	Desktop bool `json:"notify_desktop"`
	// Muted names the repositories this account silenced.
	Muted []string `json:"muted"`
	// Devices are the browsers push was enabled in.
	Devices []PushDevice `json:"devices"`
	// Push reports whether push can be configured at all here — false
	// on a forge with no notify companion, which is what lets a page
	// say so rather than offer a control that cannot work.
	Push bool `json:"push"`
}

type mePrefsSetIn struct {
	// Each family carries the channels that stay ON. The page posts the
	// WHOLE matrix (an unticked box is an absent value), so what arrives
	// is the intended end state and not a delta.
	Mention  []string `json:"mention" validate:"max=8"`
	Reply    []string `json:"reply" validate:"max=8"`
	Landing  []string `json:"landing" validate:"max=8"`
	Activity []string `json:"activity" validate:"max=8"`
	// Toasts and Desktop are "on"/"off"; an absent value is off, since
	// the page posts every switch it renders.
	Toasts  string `json:"toasts" validate:"max=8"`
	Desktop string `json:"notify_desktop" validate:"max=8"`
	// Mute replaces the muted set; repeat the key per repository.
	Mute []string `json:"mute" validate:"max=200"`
}

type mePushKeyIn struct{}

type mePushKeyOut struct {
	// PublicKey is notify's VAPID application-server key, base64url.
	// Empty when no notify is configured — a page reads that as "push
	// is not available here".
	PublicKey string `json:"publicKey"`
}

// pushKeysIn is the browser's PushSubscription.keys, nested exactly as
// PushSubscription.toJSON() spells it so a page can post the
// subscription object verbatim without reshaping it first.
type pushKeysIn struct {
	P256dh string `json:"p256dh" validate:"max=256"`
	Auth   string `json:"auth" validate:"max=256"`
}

type mePushIn struct {
	Endpoint string     `json:"endpoint" validate:"required,max=2048"`
	Keys     pushKeysIn `json:"keys"`
	// UA labels the device on the preferences page. Optional: an
	// unlabelled device is still one somebody can turn off.
	UA string `json:"ua" validate:"max=256"`
}

type mePushOut struct {
	OK bool `json:"ok"`
	// Devices is the account's device list after the change, so the
	// page that just subscribed can render it without a second call.
	Devices []PushDevice `json:"devices"`
}

type mePushDeleteIn struct {
	Endpoint string `json:"endpoint" validate:"required,max=2048"`
}

// mergeInbox builds one time-sorted page out of notify's messages and
// quarry's own attention events. Newest first, which is the order an
// inbox is read in.
//
// Both halves are shown because they are not the same thing: notify
// holds what was SENT (and survives a forge restart), the attention
// stream holds what quarry itself noticed, and a forge running no
// notify has only the second. A row present in both is not
// de-duplicated — they carry different ids and different read state,
// and guessing that two rows are one is how an inbox starts hiding mail.
func mergeInbox(msgs []InboxMessage, events []AttentionEvent, readSeq uint64, limit int) []inboxRow {
	rows := make([]inboxRow, 0, len(msgs)+len(events))
	for _, m := range msgs {
		rows = append(rows, inboxRow{
			ID: m.ID, Source: "notify", Reason: reasonOf(m.Workflow),
			Summary: m.Subject, Link: m.Link, At: m.At, Read: m.Read,
		})
	}
	for _, e := range events {
		if e.Kind == AttentionGap {
			continue
		}
		rows = append(rows, inboxRow{
			ID: localID(e.Seq), Source: "local", Reason: e.Kind, Repo: e.Repo,
			From: e.From, Summary: e.Summary, Link: e.Link, At: e.At,
			Read: e.Seq <= readSeq, Seq: e.Seq,
		})
	}
	sort.SliceStable(rows, func(i, j int) bool {
		return inboxAfter(rows[i].At, rows[j].At)
	})
	if limit > 0 && len(rows) > limit {
		rows = rows[:limit]
	}
	return rows
}

// inboxAfter reports whether a is newer than b. Parsed rather than
// string-compared because the two sources are two clocks with two
// formats: notify stamps its own CreatedAt and quarry stamps
// RFC3339Nano, and a lexical compare would sort "2026-08-16T00:00:00Z"
// against "2026-08-16T00:00:00.000000001Z" by string length. An
// unparseable stamp sorts last rather than crashing the page.
func inboxAfter(a, b string) bool {
	ta, aok := parseInboxTime(a)
	tb, bok := parseInboxTime(b)
	if aok && bok {
		return ta.After(tb)
	}
	if aok != bok {
		return aok
	}
	return a > b
}

func parseInboxTime(s string) (time.Time, bool) {
	if s == "" {
		return time.Time{}, false
	}
	for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
		if t, err := time.Parse(layout, s); err == nil {
			return t, true
		}
	}
	return time.Time{}, false
}

// registerInbox wires RFC 0011 §6's ops. Registered unconditionally,
// for registerAttention's reason: docs/api.md must be the same document
// on every node, and a forge with no notify answers honestly (an empty
// page, no push key) rather than 404ing a documented surface.
func registerInbox(r *ops.Registry, d Deps) error {
	regs := []func() error{
		func() error { return registerInboxList(r, d) },
		func() error { return registerInboxMark(r, d) },
		func() error { return registerMePrefs(r, d) },
		func() error { return registerMePush(r, d) },
	}
	for _, reg := range regs {
		if err := reg(); err != nil {
			return err
		}
	}
	return nil
}

func registerInboxList(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[inboxListIn, inboxListOut]{
		Name: "inbox.list",
		Doc: "One page of everything addressed to the signed-in account, newest first, merging the " +
			"notify inbox with quarry's own attention stream. Each row carries a `reason` (mention, " +
			"reply, landing, activity) derived from the workflow that sent it, and an `id` that " +
			"`inbox.mark` takes back — a notify message id, or `local:<seq>` for an attention event. " +
			"`before` is the `next` cursor from a previous page; local events appear on the first page " +
			"only, since the attention window is bounded and `inbox.recent` serves it whole.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/inbox",
		Run: func(ctx context.Context, in inboxListIn) (inboxListOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return inboxListOut{}, nil
			}
			limit := in.Limit
			if limit == 0 {
				limit = 50
			}
			out := inboxListOut{}
			if d.AttnRead != nil {
				out.Read = d.AttnRead.AttentionReadSeq(who)
			}
			var msgs []InboxMessage
			if d.Inbox != nil {
				var err error
				msgs, out.Next, err = d.Inbox.List(ctx, who, in.Before, limit)
				if err != nil {
					// An outage is not an empty inbox, for inbox.count's
					// reason: a page that hides outages teaches people
					// to stop trusting it.
					return inboxListOut{}, err
				}
			}
			var events []AttentionEvent
			if d.Attention != nil && in.Before == "" {
				var err error
				events, err = d.Attention.RecentAttention(who, limit)
				if err != nil {
					return inboxListOut{}, err
				}
			}
			out.Rows = mergeInbox(msgs, events, out.Read, limit)
			return out, nil
		},
	})
}

func registerInboxMark(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[inboxMarkIn, inboxMarkOut]{
		Name: "inbox.mark",
		Doc: "Mark inbox rows read, or done (archived — off the list). Takes the ids `inbox.list` " +
			"gave, or `all` for the whole inbox. Ids reach notify; `local:<seq>` ids advance the " +
			"account's attention read pointer, the same forward-only pointer `attention.read` moves, " +
			"so clearing the inbox clears the badge whichever half a row came from.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/inbox/mark",
		Run: func(ctx context.Context, in inboxMarkIn) (inboxMarkOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return inboxMarkOut{}, fmt.Errorf("marking the inbox needs a signed-in account: %w", ops.ErrInvalid)
			}
			as := in.As
			if as == "" {
				as = "read"
			}
			if !in.All && len(in.IDs) == 0 {
				return inboxMarkOut{}, fmt.Errorf("name some ids or ask for all: %w", ops.ErrInvalid)
			}
			ids, seqs := splitInboxIDs(in.IDs)
			if d.Inbox != nil && (in.All || len(ids) > 0) {
				// "done" is quarry's word; notify's state is archived.
				state := "read"
				if as == "done" {
					state = "archived"
				}
				if err := d.Inbox.Mark(ctx, who, ids, in.All, state); err != nil {
					return inboxMarkOut{}, err
				}
			}
			out := inboxMarkOut{OK: true}
			if d.AttnRead == nil {
				return out, nil
			}
			// The local pointer moves to the highest sequence the mark
			// named — or, on an all-mark, to the account's own tip. It
			// only ever goes forward (MarkAttentionRead's rule), so a
			// stale page cannot resurrect dismissed mail.
			var to uint64
			if in.All {
				if d.Attention != nil {
					tip, err := d.Attention.AttentionTip(who)
					if err != nil {
						return inboxMarkOut{}, err
					}
					to = tip
				}
			} else {
				for _, s := range seqs {
					if s > to {
						to = s
					}
				}
			}
			if to > 0 {
				if err := d.AttnRead.MarkAttentionRead(who, to); err != nil {
					return inboxMarkOut{}, err
				}
			}
			out.Seq = d.AttnRead.AttentionReadSeq(who)
			return out, nil
		},
	})
}

// currentPrefs renders the account's matrix: notify's stored rows over
// the default (everything on).
func (d Deps) currentPrefs(ctx context.Context, who string) (mePrefsOut, error) {
	out := mePrefsOut{
		Families: map[string]map[string]bool{},
		Channels: prefChannelOrder,
		Toasts:   d.prefBool(who, prefToasts),
		Desktop:  d.prefBool(who, prefDesktop),
		Push:     d.Inbox != nil,
	}
	var stored map[string]map[string]bool
	if d.Inbox != nil {
		var err error
		stored, err = d.Inbox.Preferences(ctx, who)
		if err != nil {
			return mePrefsOut{}, err
		}
	}
	for _, f := range prefFamilies {
		row := map[string]bool{}
		for _, ch := range prefChannelOrder {
			// Absent means ENABLED, which is notify's own resolution
			// rule (internal/preferences: a channel is on unless a
			// level explicitly sets it false). Defaulting to off here
			// would silence a forge that has simply never saved a
			// preference.
			on := true
			if s, ok := stored[f.workflow]; ok {
				if v, ok := s[prefChannels[ch]]; ok {
					on = v
				}
			}
			row[ch] = on
		}
		out.Families[f.family] = row
	}
	for _, repo := range strings.Fields(d.prefsRaw(who, prefMute)) {
		out.Muted = append(out.Muted, repo)
	}
	sort.Strings(out.Muted)
	if d.PushDevices != nil {
		devs, err := d.PushDevices.PushDevices(who)
		if err != nil {
			return mePrefsOut{}, err
		}
		out.Devices = devs
	}
	return out, nil
}

func (d Deps) prefsRaw(account, key string) string {
	if d.Prefs == nil {
		return ""
	}
	return d.Prefs.AccountPref(account, key)
}

func registerMePrefs(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[mePrefsIn, mePrefsOut]{
		Name: "me.prefs",
		Doc: "The signed-in account's notification preferences: RFC 0011 §6's family x channel " +
			"matrix ({mention, reply, landing, activity} x {inapp, push, email}) as notify holds it, " +
			"plus the forge-local switches quarry owns — in-page toasts, desktop notifications, the " +
			"repositories this account muted, and the browsers push was enabled in. A channel with no " +
			"stored preference is ON, which is notify's own rule.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/prefs",
		Run: func(ctx context.Context, in mePrefsIn) (mePrefsOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return mePrefsOut{}, nil
			}
			return d.currentPrefs(ctx, who)
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[mePrefsSetIn, mePrefsOut]{
		Name: "me.prefs.set",
		Doc: "Replace the signed-in account's preferences. Each family carries the channels that stay " +
			"ON (repeat the key), so the WHOLE matrix is posted and an absent channel is off — a " +
			"delta would leave a box somebody just unticked still enabled. `mute` replaces the muted " +
			"set: a muted repository still reaches the account's own inbox, it just stops being sent.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/prefs",
		Run: func(ctx context.Context, in mePrefsSetIn) (mePrefsOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return mePrefsOut{}, fmt.Errorf("preferences need a signed-in account: %w", ops.ErrInvalid)
			}
			wanted := map[string][]string{
				"mention": in.Mention, "reply": in.Reply,
				"landing": in.Landing, "activity": in.Activity,
			}
			if d.Inbox != nil {
				prefs := map[string]map[string]bool{}
				for _, f := range prefFamilies {
					on := map[string]bool{}
					for _, ch := range wanted[f.family] {
						if notifyCh, ok := prefChannels[ch]; ok {
							on[notifyCh] = true
						}
					}
					row := map[string]bool{}
					for _, ch := range prefChannelOrder {
						row[prefChannels[ch]] = on[prefChannels[ch]]
					}
					prefs[f.workflow] = row
				}
				if err := d.Inbox.SetPreferences(ctx, who, prefs); err != nil {
					return mePrefsOut{}, err
				}
			}
			if d.Prefs != nil {
				if err := d.Prefs.SetAccountPref(who, prefToasts, onOff(in.Toasts)); err != nil {
					return mePrefsOut{}, err
				}
				if err := d.Prefs.SetAccountPref(who, prefDesktop, onOff(in.Desktop)); err != nil {
					return mePrefsOut{}, err
				}
				if err := d.Prefs.SetAccountPref(who, prefMute, strings.Join(in.Mute, " ")); err != nil {
					return mePrefsOut{}, err
				}
			}
			return d.currentPrefs(ctx, who)
		},
	})
}

// onOff normalizes a switch to the two strings account_pref stores. An
// empty value is "off": the page posts every switch it renders, so an
// absent one is an unticked box, not an unmentioned setting. "off" is
// stored explicitly (rather than as the empty deletion) because the
// default is ON — a deleted row would read as "never set", which means
// on, and the switch would refuse to stay off.
func onOff(v string) string {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case "", "off", "false", "0", "no":
		return "off"
	default:
		return "on"
	}
}

func registerMePush(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[mePushKeyIn, mePushKeyOut]{
		Name: "me.push.key",
		Doc: "notify's VAPID application-server public key, base64url — what a browser passes to " +
			"pushManager.subscribe. Empty when this forge has no notify companion, which a page reads " +
			"as \"push is not available here\". quarry never holds the private half.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/push/key",
		Run: func(ctx context.Context, in mePushKeyIn) (mePushKeyOut, error) {
			if d.Inbox == nil {
				return mePushKeyOut{}, nil
			}
			key, err := d.Inbox.PushKey(ctx)
			if err != nil {
				return mePushKeyOut{}, err
			}
			return mePushKeyOut{PublicKey: key}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mePushIn, mePushOut]{
		Name: "me.push.subscribe",
		Doc: "Register a browser for push. The body is the browser's own PushSubscription JSON " +
			"({endpoint, keys:{p256dh, auth}}), forwarded to notify as subscriber credentials for the " +
			"webpush provider. quarry stores no endpoint and no key — only a hash, a user-agent label " +
			"and a timestamp, so the preferences page can list the devices somebody may want to turn off.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/push",
		Run: func(ctx context.Context, in mePushIn) (mePushOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return mePushOut{}, fmt.Errorf("a push subscription needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.Inbox == nil {
				return mePushOut{}, fmt.Errorf("this forge has no notification companion: %w", ops.ErrUnavailable)
			}
			if err := d.Inbox.PushSubscribe(ctx, who, in.Endpoint, in.Keys.P256dh, in.Keys.Auth); err != nil {
				return mePushOut{}, err
			}
			out := mePushOut{OK: true}
			if d.PushDevices != nil {
				if err := d.PushDevices.AddPushDevice(who, in.Endpoint, in.UA); err != nil {
					return mePushOut{}, err
				}
				devs, err := d.PushDevices.PushDevices(who)
				if err != nil {
					return mePushOut{}, err
				}
				out.Devices = devs
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[mePushDeleteIn, mePushOut]{
		Name: "me.push.unsubscribe",
		Doc: "Stop pushing to one browser: drops the endpoint's credential in notify and forgets the " +
			"device row. An endpoint the push service already discarded is treated as done, since that " +
			"is the end state asked for.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/push/delete",
		Run: func(ctx context.Context, in mePushDeleteIn) (mePushOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return mePushOut{}, fmt.Errorf("removing a push subscription needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.Inbox != nil {
				if err := d.Inbox.PushUnsubscribe(ctx, who, in.Endpoint); err != nil {
					return mePushOut{}, err
				}
			}
			out := mePushOut{OK: true}
			if d.PushDevices != nil {
				if err := d.PushDevices.DeletePushDevice(who, in.Endpoint); err != nil {
					return mePushOut{}, err
				}
				devs, err := d.PushDevices.PushDevices(who)
				if err != nil {
					return mePushOut{}, err
				}
				out.Devices = devs
			}
			return out, nil
		},
	})
}