Ir para o conteúdo
Trilha
Capítulos

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 nada

Uma 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.

app/…/page.go
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
resultado
AssuntoTipoEstadoPrazo
Renovação de contrato — AcmecontratoPending
Relatório de despesas — MarçodespesaApprovedAna Souza
1
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"}

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: decide

A 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: DecideByLink consome 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 mesmo ErrLinkInvalid, 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-referrer e X-Robots-Tag: noindex: o endereço é a credencial.
  • O TTL padrão é 7 dias, e o relógio de prazos esquece os links vencidos. Um pedido que ninguém decide continua expirando pelo próprio Due.

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.