Skip to content
Trilha
Chapters

Reference

approval

Approvals, Request, Decide, On, Inbox and the states — the API of the approval package, the queue that waits for a person.

import "github.com/emersonjoe/trilha/approval" — the other half of the work an application does: the part that waits for a person. The task package runs what a machine can finish on its own; this one is the queue every business application grows anyway — things somebody has to approve, reject or let expire.

It answers the four things such a queue always needs and that nobody writes the first time: an owner, a deadline, the reason written down, and who decided.

var Fila = approval.New(approval.Options{
	Roles: func(c *trilha.Ctx) []string { return sessao.Atual(c).Roles },
})

id, err := Fila.Open(c, approval.Request{
	Kind:    "eliminacao",           // groups the queue, and what On is registered for
	Subject: "Listagem 2024/07",     // the line somebody reads
	Target:  "/admin/retencao/123",  // where the thing being decided lives
	Assign:  approval.Role("cpad"),  // or approval.User(id)
	Due:     time.Now().Add(72 * time.Hour),
})

err = Fila.Decide(c, id, approval.Approved, "ok pelo quórum")

What it decides for you, and what it does not#

Who may decide is the package's answer, checked inside Decide and not on the screen: a screen that hides a button is a screen, and the address behind it is still an address. Roles is how a request assigned to a role finds its people — a function you supply, because this package does not know how you authenticate, and a check it guessed would look like a guarantee without being one. MayDecide(c, rec) is that same check, exported so the screen asks the package instead of reimplementing the rule: the buttons it draws and the decision Decide accepts can never disagree. When they do, Decide answers approval.ErrNotYours; an id that is not a request answers approval.ErrUnknown. Who a request is assigned to is an approval.Assignee — what approval.Role(name) and approval.User(id) build.

What a decision means is yours. On(kind, fn) runs after the decision is written, and that is where the application deletes the thing, sends the mail or emits the webhook. Its error does not undo the decision: a person chose, and it is recorded; a mail server being down is not a reason to pretend they did not. Making that work survive a failure is the handler's job — a task, a webhook.

The deadline expires on its own, on a clock in this process, for the same reason the task package sweeps its own: an application that needs a cron to be correct is an application that is wrong on the day the cron does not run. The sweep is Expire(ctx), exported and returning how many it closed, so a test moves the deadline by hand and asserts the number instead of waiting for a tick.

The states#

pending, approved, rejected, withdrawn, expired — a registered trilha.Enum (approval.States), so ui.Status colours them and the enum= tag validates them without the application declaring the list a second time.

In Go they are constants: approval.Pending, approval.Approved, approval.Rejected, approval.Withdrawn and approval.Expired. The first four are a decision somebody made and travel into Decide; Expired is the only state the package writes on its own, which is why it is not a decision you can pass.

Committees#

One vote is not always the whole decision. Request.Quorum — zero or one keeps today's behaviour, closing on the first vote — asks for more than one distinct person before the request closes. Decide keeps accumulating a Vote (By, State, Reason, At) into Record.Votes and holding the record Pending until len(Votes) == Quorum; the same person calling Decide twice gets approval.ErrAlreadyVoted instead of a second vote. Once the quorum closes, State, Decided, By and the On handler run exactly as they do for a single-vote request — a committee is the same request, counted more than once.

id, _ := Fila.Open(c, approval.Request{
	Kind: "eliminacao", Subject: "Listagem 2024/07", Assign: approval.Role("cpad"),
	Quorum: 3, // three distinct "cpad" members must decide
})

The screen#

ui.Inbox(c, rows, ui.InboxOpts{Decide: "/admin/aprovacoes", CSRF: trilha.CSRFInput(c)})
ui.InboxBadge(len(pending))   // the number on a menu item; zero draws nothing

A table and two forms, no JavaScript. The deadline is written by ui.Relative, so "in 3 days" and "2 days ago" are the same sentence in the reader's language, and a late row carries ui-late. The decision and the reason travel in the same form: a reason typed into a field that a second click discards is a reason nobody wrote.

A row whose action is not a plain approve/reject — a form step, say — sets InboxRow.Action (any h.Node), which replaces the default two buttons in that row's cell; a row without it keeps drawing them, as before. InboxRow.Progress ("1/3") draws next to the row's state, for a committee request that has not yet closed.

trilha add approvals writes the package wired up, the screen and the test.

app/…/page.go
ui.Inbox(c, rows, ui.InboxOpts{Decide: "/admin/approvals", CSRF: trilha.CSRFInput(c)})
ui.InboxBadge(len(pending))   // the number on a menu item; zero draws nothing
result
SubjectKindStateDue
Contract renewal — AcmecontractPending
Expense report — MarchexpenseApprovedAna Souza
1
Use this pattern — approval-inbox

What waits for a person's decision, with approve and reject and the reason in the same form.

Components

ui.Inbox ui.InboxRow ui.InboxOpts ui.PageHeader ui.Stack

Data it needs

Waiting(c) ([]approval.Record, error), by default the *approval.Approvals Setup provided (trilha add approvals), filtered to what the person may decide; Decide checks it again.

Accessibility

  • Each row's buttons name the request they decide, not only "Approve".
  • A late row says so in text as well as in color.
  • The reason field has a label and is part of the same form as the buttons.

The whole page.go (compiles, at most 60 lines)

// Package approvalinbox is the approval-inbox pattern: what waits for whoever
// is reading, and the two buttons. Who may decide is approval's answer, not
// this screen's.
package approvalinbox

import (
	"errors"
	"net/http"
	"time"

	"github.com/emersonjoe/trilha"
	"github.com/emersonjoe/trilha/approval"
	"github.com/emersonjoe/trilha/h"
	"github.com/emersonjoe/trilha/ui"
)

// Waiting is what the inbox shows: the queue Setup provided (trilha add
// approvals), filtered to what this person may decide.
var Waiting = func(c *trilha.Ctx) ([]approval.Record, error) {
	return trilha.Use[*approval.Approvals](c).Inbox(c, approval.ListParams{})
}

// Page renders the inbox.
func Page(c *trilha.Ctx) (h.Node, error) {
	records, err := Waiting(c)
	if err != nil {
		return nil, err
	}
	now := time.Now()
	rows := make([]ui.InboxRow, 0, len(records))
	for _, r := range records {
		rows = append(rows, ui.InboxRow{ID: r.ID, Kind: r.Kind, Subject: r.Subject, Target: r.Target,
			State: r.State, Due: r.Due, Late: r.Late(now), By: r.By, Reason: r.Reason})
	}
	return ui.Stack(ui.PageHeader("Approvals"), ui.Inbox(c, rows, ui.InboxOpts{
		Decide: c.Request().URL.Path, CSRF: trilha.CSRFInput(c), Empty: "Nothing is waiting for you.",
	})), nil
}

// POST decides. The decision and the reason travel in the same form, and the
// package writes who decided to the audit trail.
func POST(c *trilha.Ctx) error {
	err := trilha.Use[*approval.Approvals](c).Decide(c, c.Form("id"), c.Form("decision"), c.Form("reason"))
	if errors.Is(err, approval.ErrNotYours) {
		return trilha.Errorf(http.StatusForbidden, "this decision is not yours")
	}
	if err != nil {
		return err
	}
	c.Flash(ui.FlashSuccess, "Decided.")
	return c.Redirect(c.Request().URL.Path)
}

trilha ui patterns approval-inbox · get_pattern {"name":"approval-inbox"}

Approval without an account — "confirm your subscription", "approve the payment", the owner of a site with no login approving a trial from an e-mail. Whoever holds the link decides, once.

token, err := q.Link(ctx, id, approval.LinkOptions{To: "owner@org.br", TTL: 7 * 24 * time.Hour})
// mail "https://site/aprovar/" + token

func (q *Approvals) ByLink(c *trilha.Ctx, token string) (Record, error)            // GET: shows
func (q *Approvals) DecideByLink(c *trilha.Ctx, token, state, reason string) error // POST: decides

The page is yours, in app/aprovar/{token}/page.go, where every other page lives: Page calls ByLink, the POST calls DecideByLink. GET only shows — the link preview of a mail client fetches the address, and fetching must not approve anything; the decision is a POST, which a page route checks for CSRF.

  • The token is 32 random bytes; the store keeps its SHA-256 (Link.Hash), never the token.
  • Single use: DecideByLink takes the link before deciding, so two clicks are one decision; afterwards the address answers 404. So does an unknown, expired or no-longer-pending one — the same ErrLinkInvalid, because saying which tells a guesser how close they got.
  • The decision is recorded as By: "link:owner@org.br", in the record and in the audit trail.
  • Both calls set Cache-Control: no-store, Referrer-Policy: no-referrer and X-Robots-Tag: noindex: the address is the credential.
  • TTL defaults to 7 days, and the deadline clock forgets expired links. A request that nobody decides still expires by its own Due.

Links live in the Store when it also implements LinkStore (Memory() does), and in memory otherwise.

Store#

Memory() is the default and the right one for a single process. A table behind the same three methods — Save, Get, List — is the next step, and no screen changes: the framework does not own your schema.