Referência
approval
Approvals, Request, Decide, On, Inbox e os estados — a API do pacote approval, a fila que espera uma pessoa.
import "github.com/emersonjoe/trilha/approval" — a outra metade do trabalho de uma aplicação: a parte que espera uma pessoa. O pacote task roda o que a máquina termina sozinha; este é a fila que todo app de negócio ganha de qualquer jeito — coisas que alguém precisa aprovar, rejeitar, ou deixar vencer.
Ele responde as quatro coisas que essa fila sempre precisa e que ninguém escreve na primeira vez: um dono, um prazo, o motivo registrado, e quem decidiu.
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", // agrupa a fila, e é para o que o On é registrado
Subject: "Listagem 2024/07", // a linha que alguém lê
Target: "/admin/retencao/123", // onde mora a coisa que está sendo decidida
Assign: approval.Role("cpad"), // ou approval.User(id)
Due: time.Now().Add(72 * time.Hour),
})
err = Fila.Decide(c, id, approval.Approved, "ok pelo quórum")O que ele decide por você, e o que não#
Quem pode decidir é resposta do pacote, conferida dentro do Decide e não na tela: uma tela que esconde um botão é uma tela, e o endereço atrás dele continua sendo um endereço. O Roles é como um pedido atribuído a um papel encontra a gente dele — uma função que você passa, porque este pacote não sabe como você autentica, e uma checagem que ele adivinhasse pareceria garantia sem ser uma. O MayDecide(c, rec) é essa mesma checagem, exportada para a tela perguntar ao pacote em vez de reimplementar a regra: os botões que ela desenha e a decisão que o Decide aceita nunca podem discordar. Quando discordam, o Decide responde approval.ErrNotYours; um id que não é pedido responde approval.ErrUnknown. A quem o pedido é atribuído é um approval.Assignee — o que o approval.Role(nome) e o approval.User(id) constroem.
O que uma decisão significa é seu. O On(kind, fn) roda depois de a decisão ser gravada, e é ali que a aplicação apaga a coisa, manda o e-mail ou emite o webhook. O erro dele não desfaz a decisão: uma pessoa escolheu, e está registrado; um servidor de e-mail fora do ar não é motivo para fingir que ela não escolheu. Fazer esse trabalho sobreviver a uma falha é do gancho — uma tarefa, um webhook.
O prazo vence sozinho, num relógio deste processo, pelo mesmo motivo que o task varre o dele: uma aplicação que precisa de um cron para estar certa é uma aplicação errada no dia em que o cron não roda. A varredura é o Expire(ctx), exportado e devolvendo quantos fechou, para um teste mover o prazo na mão e conferir o número em vez de esperar um tique.
Os estados#
pending, approved, rejected, withdrawn, expired — um trilha.Enum registrado (approval.States), então o ui.Status já os colore e a tag enum= já os valida sem a aplicação declarar a lista uma segunda vez.
Em Go são constantes: approval.Pending, approval.Approved, approval.Rejected, approval.Withdrawn e approval.Expired. As quatro primeiras são decisão de alguém e entram no Decide; Expired é o único estado que o pacote escreve sozinho, e por isso não é uma decisão que se possa passar.
Comitês#
Um voto nem sempre é a decisão inteira. Request.Quorum — zero ou um mantém o comportamento de hoje, fechando no primeiro voto — pede mais de uma pessoa distinta antes de o pedido fechar. Decide acumula um Vote (By, State, Reason, At) em Record.Votes e mantém o registro Pending até len(Votes) == Quorum; a mesma pessoa chamando Decide de novo recebe approval.ErrAlreadyVoted em vez de contar um segundo voto. Quando o quórum fecha, State, Decided, By e o gancho On rodam exatamente como num pedido de voto único — um comitê é o mesmo pedido, contado mais de uma vez.
id, _ := Fila.Open(c, approval.Request{
Kind: "eliminacao", Subject: "Listagem 2024/07", Assign: approval.Role("cpad"),
Quorum: 3, // três membros distintos de "cpad" precisam decidir
})A tela#
ui.Inbox(c, linhas, ui.InboxOpts{Decide: "/admin/aprovacoes", CSRF: trilha.CSRFInput(c)})
ui.InboxBadge(len(pendentes)) // o número no item de menu; zero não desenha nadaUma tabela e dois formulários, sem JavaScript. O prazo é escrito pelo ui.Relative, então "em 3 dias" e "há 2 dias" são a mesma frase no idioma de quem lê, e a linha atrasada leva ui-late. A decisão e o motivo viajam no mesmo formulário: um motivo digitado num campo que um segundo clique descarta é um motivo que ninguém escreveu.
Uma linha cuja ação não é um simples aprovar/rejeitar — uma etapa de formulário, por exemplo — preenche InboxRow.Action (qualquer h.Node), que substitui os dois botões padrão na célula daquela linha; uma linha sem Action continua desenhando os botões, como antes. InboxRow.Progress ("1/3") desenha ao lado do estado da linha, para um pedido de comitê que ainda não fechou.
O trilha add approvals escreve o pacote ligado, a tela e o teste.
ui.Inbox(c, linhas, ui.InboxOpts{Decide: "/admin/aprovacoes", CSRF: trilha.CSRFInput(c)})
ui.InboxBadge(len(pendentes)) // o número no item de menu; zero não desenha nada| Assunto | Tipo | Estado | Prazo | |
|---|---|---|---|---|
| Renovação de contrato — Acme | contrato | Pending | ||
| Relatório de despesas — Março | despesa | Approved | Ana Souza |
Usar este padrão — approval-inbox
O que espera a decisão de uma pessoa, com aprovar, recusar e o motivo no mesmo formulário.
Componentes
ui.Inbox ui.InboxRow ui.InboxOpts ui.PageHeader ui.Stack
Dados de que precisa
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.
Acessibilidade
- Os botões de cada linha nomeiam o pedido que decidem, e não só "Aprovar".
- Uma linha atrasada diz isso em texto, e não só na cor.
- O campo do motivo tem rótulo e faz parte do mesmo formulário dos botões.
O page.go inteiro (compila, no máximo 60 linhas)
// 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"}
Links de decisão#
Aprovação sem conta — "confirme sua inscrição", "aprove o pagamento", o dono de um site sem login aprovando um teste grátis pelo e-mail. Quem tem o link decide, uma vez.
token, err := q.Link(ctx, id, approval.LinkOptions{To: "dono@org.br", TTL: 7 * 24 * time.Hour})
// mande "https://site/aprovar/" + token
func (q *Approvals) ByLink(c *trilha.Ctx, token string) (Record, error) // GET: mostra
func (q *Approvals) DecideByLink(c *trilha.Ctx, token, state, reason string) error // POST: decideA página é sua, em app/aprovar/{token}/page.go, onde moram as outras: o Page chama ByLink, o POST chama DecideByLink. GET só mostra — a pré-visualização de link do cliente de e-mail busca o endereço, e buscar não pode aprovar nada; a decisão é um POST, que a rota de página confere com CSRF.
- O token tem 32 bytes aleatórios; o store guarda o SHA-256 dele (
Link.Hash), nunca o token. - Uso único:
DecideByLinkconsome o link antes de decidir, então dois cliques são uma decisão; depois, o endereço responde 404. Também o desconhecido, o vencido e o de pedido que não está mais pendente — o mesmoErrLinkInvalid, porque dizer qual conta a quem chuta o quanto chegou perto. - A decisão fica registrada como
By: "link:dono@org.br", no registro e na trilha de auditoria. - As duas chamadas põem
Cache-Control: no-store,Referrer-Policy: no-referrereX-Robots-Tag: noindex: o endereço é a credencial. - O
TTLpadrão é 7 dias, e o relógio de prazos esquece os links vencidos. Um pedido que ninguém decide continua expirando pelo próprioDue.
Os links moram no Store quando ele também implementa LinkStore (o Memory() implementa), e em memória nos outros casos.
Store#
Memory() é o padrão e o certo para um processo só. Uma tabela atrás dos mesmos três métodos — Save, Get, List — é o passo seguinte, e nenhuma tela muda: o framework não é dono do seu esquema.