Code

Invite end to end, in one transaction

Six files as tabs, bottom-up: the view model, its mappers, the invite handler, then the owner detail page that shows the form and roster. signing.gosigning_view.goweb/signing.godocuments.godocuments.templrouter.go.

1. internal/handlers/signing.go (new) — InviteSigners and parseEmails. The handler is the transaction in the flesh:

tx, err := h.Pool.Begin(r.Context())
// ...
defer tx.Rollback(context.Background()) // no-op after a successful Commit
q := h.Queries.WithTx(tx)               // same query methods, bound to the tx

for _, addr := range emails {
	raw, hash, err := auth.GenerateToken()          // raw → the link, hash → the row
	q.CreateSigner(r.Context(), db.CreateSignerParams{ /* ...TokenHash: hash... */ })
	invites = append(invites, invite{email: addr, link: h.Cfg.BaseURL + "/sign/" + raw})
}
q.MarkDocumentSent(r.Context(), /* ... */)
tx.Commit(r.Context())

defer tx.Rollback is the safety net: if any step returns early, the rollback fires and nothing is committed; after a successful Commit the rollback is a no-op. Emails are parsed and sent after the commit, so the console sender only prints links for a genuinely-sent document. parseEmails splits the textarea (newline/comma/space), normalizes, validates, and de-duplicates; an empty or all-invalid list re-renders the detail page with an error instead of sending.

2. internal/handlers/signing_view.go (new) — viewSigner maps a db.Signer into a web.SignerView, and viewSigners builds the SignerRoster (total + signed count). The signed-state fields are wired now; they stay empty until someone signs next lesson.

3. internal/web/signing.go (new) — the SignerView and SignerRoster view structs. Plain strings, no pgtype.

4. internal/handlers/documents.go (modify) — ViewDocument now delegates to a new renderDocumentDetail, which loads the signer roster and renders the detail page. Both the view route and a failed invite render through this one path.

5. internal/web/documents.templ (modify) — DocumentDetail grows two parameters (roster, errMsg) and two sections: an invite form while the document is a draft, and the signer roster once it is sent.

6. internal/handlers/router.go (modify) — one route in the authenticated group: pr.Post("/documents/{id}/invite", h.InviteSigners).

Verify — reseed, run, log in, open a draft's detail page:

  • The Invite signers form is there. Enter two addresses (one per line) and Send for signature.
  • The page reloads showing the document as sent, with a Signers (0 of 2 signed) roster — both pending.
  • The dev mailer printed each signing link to your terminal:
────────────────────────────────────────────────────────
To:      alice@example.com
Subject: Please sign "contract.pdf"

You've been invited to sign "contract.pdf" on SignFlow.

Open your signing link (valid 30 days):
http://localhost:8080/sign/9f3b2c...e1
────────────────────────────────────────────────────────
  • Confirm the DB stores only hashes, and the invite was atomic (document sent and both signer rows present):
$ psql "$DATABASE_URL" -c "select email, status, left(token_hash,16) as token_hash from signers;"
      email        | status  |    token_hash
-------------------+---------+------------------
 alice@example.com | pending | 3a7f9c2b1e0d4f6a
 bob@example.com   | pending | c1d2e3f4a5b6c7d8

The raw token lives only in the emailed link; the row holds its SHA-256. Opening that link is next lesson.