oficina / fichas Public

The surface: list, card, call log, and a confirm page for delete

Four screens and the read path behind them. Everything works with scripts
off — every mutation is a form POST, and the delete confirm is a page with
its own URL rather than a modal, per AGENTS.md. The vendored ui partials do
the work: page-header, field/field-select/field-date/field-textarea,
empty-state, status-pill, badge, callout, confirm-form.

The read path unseals, which is §4.1's argument made concrete: sealed is
not unqueryable, the values come back into memory to be read and ordered.
Slice 1 reads straight from the tables; the in-memory index replaces these
ORDER BYs when there is enough data for it to matter, and §4.2 carries the
trigger.

Empty states say what the screen is for rather than that it is empty, and
a missing card is 404 and never 403 — the suite's rule that a row you
cannot have is a row that does not exist. formValues takes only the keys
the card type defines, so a request body is never bound onto a model and
an unexpected field is ignored because it was never asked for.

The part worth arguing with: this app cannot name who is writing.
FICHAS_DEV_MEMBER attributes every write to one configured member, and
LoadConfig REFUSES it on any origin that is not loopback, with tests
including hostnames that merely look local. The actor seam never falls
back to AppBecause — borrowing the app-as-itself actor for a person's
write is exactly the attribution failure ruling 9 is about.

So the app is claimed on CARLOS as fichas in account bds, and deliberately
not deployed. The README now carries the DEK instructions: 32 random bytes
base64, not `carlos secrets genkey` which is a different key at a different
layer, with the warning that losing it makes the database unreadable and no
fallback is generated on purpose.

Sign-in is the gate before a deploy. An event log that records an actor for
every change, on a deployment that cannot tell one person from another,
records the same answer for everyone — which is worse than recording
nothing, because it looks like an answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9c2d39ae5a533cfef46b4e8c17fb6eb4d9c3f0a5 parent a45c9a7
17 files changed, +1026 −23
  • README.md +58 −0
  • cmd/fichas/main.go +1 −1
  • internal/fichas/app.go +12 −6
  • internal/fichas/catalogue.go +13 −0
  • internal/fichas/config.go +34 −0
  • internal/fichas/config_test.go +45 −0
  • internal/fichas/handlers.go +209 −3
  • internal/fichas/read.go +336 −0
  • internal/fichas/render.go +17 −2
  • internal/fichas/templates/card_delete.html +16 −0
  • internal/fichas/templates/card_new.html +20 −0
  • internal/fichas/templates/card_show.html +53 −0
  • internal/fichas/templates/index.html +30 −1
  • internal/fichastest/gates_test.go +1 −1
  • internal/fichastest/harness_test.go +35 −7
  • internal/fichastest/index_test.go +8 −2
  • internal/fichastest/surface_test.go +138 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.155s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	(cached)
ok  	amadan.net/oficina/fichas/internal/fichastest	0.089s
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/README.md b/README.md
index 9af1af6..92e7f96 100644
--- a/README.md
+++ b/README.md
@@ -64,3 +64,61 @@ It needs a Chromium on PATH, via `RASTRILLO_CHROME`, or in a Playwright
cache. **A skip is not a pass:** with no browser it fails unless
`RASTRILLO_BROWSER_OPTIONAL=1` makes the skip a deliberate, visible choice.
`make ci` runs the plain suite only.
+
+## Deploying, and the DEK
+
+The app is claimed on CARLOS as `fichas` in account `bds`
+(`https://console.carloku.com/a/bds/apps/fichas`). It is **not deployed**,
+and it should not be until sign-in lands — see the warning at the end.
+
+### 1. Mint the DEK
+
+The DEK is 32 random bytes, base64. It is not `carlos secrets genkey`,
+which mints the *secrets* keypair that seals config — a different thing at
+a different layer.
+
+```sh
+head -c 32 /dev/urandom | base64
+```
+
+**Keep a copy somewhere you will still have it in a year.** Everything in
+fichas — every card value, every call logged, every label — is sealed
+under this key and under nothing else. Lose it and the database is
+unreadable: there is no recovery path, by design, and no fallback key is
+generated because a fallback would seal this boot's cards under a key the
+next boot cannot derive and lose the data quietly instead of refusing
+loudly. memoria keeps its copy at `memoriadrive/DEK-production.txt`
+alongside `carlos secrets`; do the same here, or better.
+
+### 2. Set it, with the origin
+
+```sh
+carlos secrets set --app fichas --account bds FICHAS_DEK='<the base64>'
+carlos env set --app fichas --account bds FICHAS_ORIGIN='https://fichas.oficina.works'
+```
+
+`FICHAS_ORIGIN` is the CSRF yardstick and the base of every link the app
+emits, so it must be the host the browser is actually on. Unset, the app
+warns and falls back to loopback.
+
+### 3. Ship it
+
+```sh
+make release # refuses a dirty tree; stamps the version from git describe
+carlos deploy --app fichas --account bds --channel edge --timeout 5m \
+ releases/fichas-linux-arm64
+```
+
+### Before any of that: sign-in
+
+**This app cannot yet name who is writing.** Slice 1 has a surface and no
+sign-in, so `FICHAS_DEV_MEMBER` names one member every write is attributed
+to — and `LoadConfig` **refuses that variable on any origin that is not
+loopback**, with a test for it, because the alternative is an
+unauthenticated CRM on a public host where "who did it" is a constant.
+
+That refusal is the thing standing between this repo and a deploy. The
+event log Paul ruled in records an actor for every change; a deployment
+that cannot tell one person from another records the same answer for
+everyone, which is worse than recording nothing because it looks like an
+answer.
diff --git a/cmd/fichas/main.go b/cmd/fichas/main.go
index 605ab18..f892d10 100644
--- a/cmd/fichas/main.go
+++ b/cmd/fichas/main.go
@@ -50,7 +50,7 @@ func main() {
logger.Warn("FICHAS_ORIGIN not set; defaulting", "origin", cfg.Origin)
}
- mux, err := fichas.App(d, cfg.Origin, cfg.Sealer, logger)
+ mux, err := fichas.App(d, cfg, logger)
if err != nil {
logger.Error("build app", "err", err)
os.Exit(1)
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index b95c61c..536293f 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -10,8 +10,6 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
-
- "amadan.net/oficina/fichas/internal/fichas/seal"
)
// App wires the whole app: schema, router, static files. It returns a
@@ -26,7 +24,7 @@ import (
// rastrillo repo is the worked example to copy). Adding a subsystem
// also means adding its Schema to migrations.go's BootSchema — see
// the comment there.
-func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, error) {
+func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) {
// Apply BootSchema, not Schema: BootSchema is everything this
// app needs at boot (its own migrations plus any subsystem's),
// while Schema (migrations.go) stays just this app's own so the
@@ -48,18 +46,26 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht
// because the DEK is config and arrives after the schema does, and a
// migration that wrote labels in the clear would put the catalogue's
// words in the durable file — which is the whole thing §4.3 forbids.
- if err := Seed(d.G, sealer); err != nil {
+ if err := Seed(d.G, cfg.Sealer); err != nil {
return nil, err
}
- a := &app{db: d, sealer: sealer, logger: logger}
+ a := &app{db: d, sealer: cfg.Sealer, cfg: cfg, logger: logger}
r := chi.NewRouter()
// App-wide, from day one: every state-changing route added below
// is born covered. Origin-checking, not tokens — nothing to mint
// or forget in a form.
- r.Use(csrf.Protect(origin))
+ r.Use(csrf.Protect(cfg.Origin))
r.Get("/", a.index)
+ r.Get("/cards/new", a.newCard)
+ r.Post("/cards", a.createCard)
+ r.Get("/cards/{id}", a.showCard)
+ r.Post("/cards/{id}", a.updateCard)
+ r.Post("/cards/{id}/entries", a.addEntry)
+ // A destructive action gets its own URL, never a modal (AGENTS.md).
+ r.Get("/cards/{id}/delete", a.confirmDelete)
+ r.Post("/cards/{id}/delete", a.deleteCard)
mux := http.NewServeMux()
// The app serves its own static files — the framework never does.
diff --git a/internal/fichas/catalogue.go b/internal/fichas/catalogue.go
index 6f4469c..d1f6a54 100644
--- a/internal/fichas/catalogue.go
+++ b/internal/fichas/catalogue.go
@@ -249,3 +249,16 @@ func seedField_(tx *gorm.DB, s *seal.Sealer, cardTypeID int64, ordinal int, f se
}
return nil
}
+
+// openOptions unseals a KindEnum's permitted values.
+func openOptions(s *seal.Sealer, sealed []byte) ([]string, error) {
+ raw, err := s.Open(seal.PurposeLabel, sealed)
+ if err != nil {
+ return nil, fmt.Errorf("fichas: opening enum options: %w", err)
+ }
+ var out []string
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return nil, fmt.Errorf("fichas: decoding enum options: %w", err)
+ }
+ return out, nil
+}
diff --git a/internal/fichas/config.go b/internal/fichas/config.go
index 9bbca3b..807250f 100644
--- a/internal/fichas/config.go
+++ b/internal/fichas/config.go
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "strings"
"amadan.net/oficina/fichas/internal/fichas/seal"
)
@@ -20,6 +21,17 @@ import (
type Config struct {
Origin string
Sealer *seal.Sealer
+ // DevMember makes every write in this process act as one named
+ // member, because slice 1 has a surface and no sign-in yet.
+ //
+ // **It is a local-development opt-in and LoadConfig refuses it on any
+ // other origin.** Without that refusal the first deploy would put an
+ // unauthenticated CRM on a public host, where "who did it" is a
+ // constant and the event log — the thing Paul ruled in — records the
+ // same answer for everyone. memoria's ALLOW_LOGGED_MAIL is the same
+ // shape of guard for the same reason: the dangerous default has to be
+ // asked for out loud.
+ DevMember string
}
// ErrMissing is returned when required platform config is absent.
@@ -58,9 +70,31 @@ func LoadConfig(getenv func(string) string) (Config, error) {
if c.Origin == "" {
c.Origin = defaultOrigin
}
+
+ c.DevMember = getenv("FICHAS_DEV_MEMBER")
+ if c.DevMember != "" && !isLocal(c.Origin) {
+ return Config{}, fmt.Errorf(
+ "fichas: FICHAS_DEV_MEMBER makes every write act as one person and is local-development only, "+
+ "but FICHAS_ORIGIN is %s; sign-in has to land before this app serves anyone else", c.Origin)
+ }
return c, nil
}
+// isLocal reports whether an origin is a loopback address. Deliberately
+// strict: a hostname that merely looks local is not one.
+func isLocal(origin string) bool {
+ for _, p := range []string{"http://127.0.0.1:", "http://localhost:", "http://[::1]:"} {
+ if strings.HasPrefix(origin, p) {
+ return true
+ }
+ }
+ return origin == "http://127.0.0.1" || origin == "http://localhost"
+}
+
+// WritesAreAnonymous reports whether this process has no way to name a
+// person, which is every deployment until sign-in lands.
+func (c Config) WritesAreAnonymous() bool { return c.DevMember == "" }
+
// UsedDefaultOrigin reports whether Origin fell back, so main can say so
// out loud. A silently defaulted origin is a CSRF yardstick pointing at
// the wrong host.
diff --git a/internal/fichas/config_test.go b/internal/fichas/config_test.go
index ef3948b..512baf2 100644
--- a/internal/fichas/config_test.go
+++ b/internal/fichas/config_test.go
@@ -64,3 +64,48 @@ func TestLoadConfigTakesTheOriginWhenSet(t *testing.T) {
t.Fatal("an explicitly set origin reports as defaulted")
}
}
+
+// The guard that stops the first deploy being an unauthenticated CRM.
+func TestDevMemberIsRefusedOffLocalhost(t *testing.T) {
+ _, err := LoadConfig(env(map[string]string{
+ "FICHAS_DEK": goodDEK(),
+ "FICHAS_ORIGIN": "https://fichas.oficina.works",
+ "FICHAS_DEV_MEMBER": "ana@example.com",
+ }))
+ if err == nil {
+ t.Fatal("FICHAS_DEV_MEMBER was accepted on a public origin")
+ }
+}
+
+func TestDevMemberIsAllowedOnLocalhost(t *testing.T) {
+ for _, origin := range []string{"http://127.0.0.1:8824", "http://localhost:8824"} {
+ c, err := LoadConfig(env(map[string]string{
+ "FICHAS_DEK": goodDEK(),
+ "FICHAS_ORIGIN": origin,
+ "FICHAS_DEV_MEMBER": "ana@example.com",
+ }))
+ if err != nil {
+ t.Fatalf("%s: %v", origin, err)
+ }
+ if c.WritesAreAnonymous() {
+ t.Fatalf("%s: a dev member was set but writes still report as anonymous", origin)
+ }
+ }
+}
+
+// A hostname that merely looks local is not local.
+func TestOriginsThatOnlyLookLocalAreRefused(t *testing.T) {
+ for _, origin := range []string{
+ "https://localhost.example.com",
+ "http://127.0.0.1.example.com",
+ "http://notlocalhost:8824",
+ } {
+ if _, err := LoadConfig(env(map[string]string{
+ "FICHAS_DEK": goodDEK(),
+ "FICHAS_ORIGIN": origin,
+ "FICHAS_DEV_MEMBER": "ana@example.com",
+ })); err == nil {
+ t.Errorf("%s was treated as local", origin)
+ }
+ }
+}
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index c0b6456..91c02d5 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -1,25 +1,231 @@
package fichas
import (
+ "errors"
"log/slog"
"net/http"
+ "strconv"
+ "strings"
"github.com/carlosframework/rastrillo/db"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
"amadan.net/oficina/fichas/internal/fichas/seal"
)
-// app holds what every handler needs. When the app grows accounts,
-// this is where the sessions handle lands too.
+// app holds what every handler needs.
type app struct {
db *db.DB
// sealer is the only way anything in this app reads or writes a
// value. A handler that touches a sealed column without it is a bug
// the compiler will catch.
sealer *seal.Sealer
+ cfg Config
logger *slog.Logger
}
+// actor names who is making a change.
+//
+// Slice 1 has no sign-in, so there are exactly two answers and neither is
+// a guess: a configured local-development member, or a refusal. It never
+// falls back to the app acting as itself — AppBecause exists for honest
+// machine writes and borrowing it here would put "fichas did this" on
+// every row a person typed, which is the attribution failure suite ruling
+// 9 is about.
+func (a *app) actor(r *http.Request) (Actor, error) {
+ if a.cfg.DevMember == "" {
+ return Actor{}, errors.New("fichas: this instance cannot name who is writing; sign-in has not landed yet")
+ }
+ m, err := memberByAddress(r.Context(), a.db.G, a.sealer, a.cfg.DevMember)
+ if err != nil {
+ return Actor{}, err
+ }
+ return Person(m.ID), nil
+}
+
func (a *app) index(w http.ResponseWriter, r *http.Request) {
- render(w, "index", nil)
+ cards, err := ListCards(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ render(w, "index", map[string]any{
+ "Cards": cards,
+ "Anonymous": a.cfg.WritesAreAnonymous(),
+ })
+}
+
+func (a *app) newCard(w http.ResponseWriter, r *http.Request) {
+ fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ render(w, "card_new", map[string]any{
+ "Fields": fields,
+ "Anonymous": a.cfg.WritesAreAnonymous(),
+ })
+}
+
+func (a *app) createCard(w http.ResponseWriter, r *http.Request) {
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ card, err := CreateCard(r.Context(), a.db.G, a.sealer, TypeCustomer, actor, formValues(r, fields))
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/cards/"+strconv.FormatInt(card.ID, 10), http.StatusSeeOther)
+}
+
+func (a *app) showCard(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ card, err := LoadCard(r.Context(), a.db.G, a.sealer, id)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ entryFields, err := EntryFields(r.Context(), a.db.G, a.sealer, TypeCallLog)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ render(w, "card_show", map[string]any{
+ "Card": card,
+ "EntryFields": entryFields,
+ "Anonymous": a.cfg.WritesAreAnonymous(),
+ })
+}
+
+func (a *app) updateCard(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ card, err := LoadCard(r.Context(), a.db.G, a.sealer, id)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ defs := make([]FieldView, len(card.Fields))
+ copy(defs, card.Fields)
+ if err := UpdateCard(r.Context(), a.db.G, a.sealer, id, actor, formValues(r, defs)); err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/cards/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+}
+
+func (a *app) addEntry(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ fields, err := EntryFields(r.Context(), a.db.G, a.sealer, TypeCallLog)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ if _, err := AddEntry(r.Context(), a.db.G, a.sealer, id, actor, TypeCallLog, "manual", formValues(r, fields)); err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/cards/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+}
+
+// confirmDelete is a PAGE, not a modal: AGENTS.md requires a destructive
+// action to have its own URL, so that it is reachable, linkable and works
+// with scripts off.
+func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ card, err := LoadCard(r.Context(), a.db.G, a.sealer, id)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ render(w, "card_delete", map[string]any{"Card": card, "Anonymous": a.cfg.WritesAreAnonymous()})
+}
+
+func (a *app) deleteCard(w http.ResponseWriter, r *http.Request) {
+ id, ok := pathID(w, r)
+ if !ok {
+ return
+ }
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ if err := DeleteCard(r.Context(), a.db.G, a.sealer, id, actor); err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// formValues pulls exactly the fields the type defines out of the form.
+//
+// **Never binds the request body onto a model** (AGENTS.md): the field
+// list comes from the card type, and anything else a form posts is
+// ignored because it was never asked for.
+func formValues(r *http.Request, fields []FieldView) map[string]string {
+ _ = r.ParseForm()
+ out := map[string]string{}
+ for _, f := range fields {
+ v, present := r.Form[f.Def.Key]
+ if !present {
+ continue
+ }
+ out[f.Def.Key] = strings.TrimSpace(strings.Join(v, ""))
+ }
+ return out
+}
+
+func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil || id <= 0 {
+ http.NotFound(w, r)
+ return 0, false
+ }
+ return id, true
+}
+
+// notFoundOrFail 404s a missing row rather than 403ing or 500ing it — the
+// suite's rule that a row you cannot have is a row that is not there.
+func (a *app) notFoundOrFail(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ a.fail(w, r, err)
+}
+
+func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) {
+ a.logger.Error("request failed", "path", r.URL.Path, "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
}
diff --git a/internal/fichas/read.go b/internal/fichas/read.go
new file mode 100644
index 0000000..4df66ca
--- /dev/null
+++ b/internal/fichas/read.go
@@ -0,0 +1,336 @@
+package fichas
+
+import (
+ "context"
+ "fmt"
+ "sort"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+ "gorm.io/gorm"
+)
+
+// The read path. Everything here unseals, which is the point: §4.1's whole
+// argument is that sealed is not unqueryable, because the values come back
+// into memory to be read, sorted and grouped.
+//
+// Slice 1 reads straight from the tables. The in-memory index §4.1 talks
+// about is what replaces these ORDER BYs once there is enough data for it
+// to matter; there is a trigger for that in §4.2 and no point paying for it
+// at a few hundred cards.
+
+// FieldView is one field of a card or entry, ready to render.
+type FieldView struct {
+ Def FieldDef
+ Label string
+ Value string
+ // Options is a KindEnum's permitted values, unsealed.
+ Options []string
+}
+
+// CardView is a card with its type, its fields and their values.
+type CardView struct {
+ Card Card
+ TypeKey string
+ TypeLabel string
+ Fields []FieldView
+ Entries []EntryView
+}
+
+// Title is what a card is called in a list. The first printed text field
+// with a value, because that is the name-shaped one; falls back to the id
+// so a card with nothing in it is still reachable.
+func (c CardView) Title() string {
+ for _, f := range c.Fields {
+ if f.Def.Key == FieldName && f.Value != "" {
+ return f.Value
+ }
+ }
+ for _, f := range c.Fields {
+ if f.Def.Kind == KindText && f.Value != "" {
+ return f.Value
+ }
+ }
+ return fmt.Sprintf("Card %d", c.Card.ID)
+}
+
+// Field returns one field by key, for a template that wants a specific one.
+func (c CardView) Field(key string) FieldView {
+ for _, f := range c.Fields {
+ if f.Def.Key == key {
+ return f
+ }
+ }
+ return FieldView{}
+}
+
+// EntryView is an entry with its values.
+type EntryView struct {
+ Entry Entry
+ TypeLabel string
+ Fields []FieldView
+}
+
+// Body is the entry's main text, for a compact rendering.
+func (e EntryView) Body() string {
+ for _, f := range e.Fields {
+ if f.Def.Key == FieldBody {
+ return f.Value
+ }
+ }
+ return ""
+}
+
+func openLabel(s *seal.Sealer, b []byte) (string, error) {
+ if len(b) == 0 {
+ return "", nil
+ }
+ raw, err := s.Open(seal.PurposeLabel, b)
+ if err != nil {
+ return "", fmt.Errorf("fichas: opening a label: %w", err)
+ }
+ return string(raw), nil
+}
+
+// fieldViews builds the ordered field list for a type, filling in values
+// from valuesByDef.
+func fieldViews(s *seal.Sealer, defs []FieldDef, valuesByDef map[int64][]byte) ([]FieldView, error) {
+ sort.Slice(defs, func(i, j int) bool { return defs[i].Ordinal < defs[j].Ordinal })
+ out := make([]FieldView, 0, len(defs))
+ for _, d := range defs {
+ label, err := openLabel(s, d.SealedLabel)
+ if err != nil {
+ return nil, err
+ }
+ fv := FieldView{Def: d, Label: label}
+ if sealed, ok := valuesByDef[d.ID]; ok && len(sealed) > 0 {
+ raw, err := s.Open(seal.PurposeValue, sealed)
+ if err != nil {
+ return nil, fmt.Errorf("fichas: opening %s: %w", d.Key, err)
+ }
+ fv.Value = string(raw)
+ }
+ if len(d.SealedOptions) > 0 {
+ opts, err := openOptions(s, d.SealedOptions)
+ if err != nil {
+ return nil, err
+ }
+ fv.Options = opts
+ }
+ out = append(out, fv)
+ }
+ return out, nil
+}
+
+// ListCards returns every card of a type that is not gone, newest first.
+func ListCards(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string) ([]CardView, error) {
+ g := db.WithContext(ctx)
+ ct, err := typeByKey(g, typeKey, ScopeCard)
+ if err != nil {
+ return nil, err
+ }
+ typeLabel, err := openLabel(s, ct.SealedLabel)
+ if err != nil {
+ return nil, err
+ }
+
+ var defs []FieldDef
+ if err := g.Where("card_type_id = ?", ct.ID).Find(&defs).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading field definitions: %w", err)
+ }
+
+ var cards []Card
+ if err := g.Where("card_type_id = ? AND gone = ?", ct.ID, false).
+ Order("updated_at desc").Find(&cards).Error; err != nil {
+ return nil, fmt.Errorf("fichas: listing cards: %w", err)
+ }
+ if len(cards) == 0 {
+ return nil, nil
+ }
+
+ ids := make([]int64, len(cards))
+ for i, c := range cards {
+ ids[i] = c.ID
+ }
+ var values []CardValue
+ if err := g.Where("card_id IN ?", ids).Find(&values).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading values: %w", err)
+ }
+ byCard := map[int64]map[int64][]byte{}
+ for _, v := range values {
+ if byCard[v.CardID] == nil {
+ byCard[v.CardID] = map[int64][]byte{}
+ }
+ byCard[v.CardID][v.FieldDefID] = v.SealedValue
+ }
+
+ out := make([]CardView, 0, len(cards))
+ for _, c := range cards {
+ fields, err := fieldViews(s, append([]FieldDef(nil), defs...), byCard[c.ID])
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, CardView{Card: c, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields})
+ }
+ return out, nil
+}
+
+// LoadCard returns one card with its fields and its entries, newest entry
+// first. A gone card is not found: retirement is by feed, and a retired
+// card is not a page.
+func LoadCard(ctx context.Context, db *gorm.DB, s *seal.Sealer, cardID int64) (CardView, error) {
+ g := db.WithContext(ctx)
+ var card Card
+ if err := g.Where("id = ? AND gone = ?", cardID, false).First(&card).Error; err != nil {
+ return CardView{}, err
+ }
+
+ var ct CardType
+ if err := g.First(&ct, card.CardTypeID).Error; err != nil {
+ return CardView{}, fmt.Errorf("fichas: loading the card type: %w", err)
+ }
+ typeLabel, err := openLabel(s, ct.SealedLabel)
+ if err != nil {
+ return CardView{}, err
+ }
+
+ var defs []FieldDef
+ if err := g.Where("card_type_id = ?", ct.ID).Find(&defs).Error; err != nil {
+ return CardView{}, fmt.Errorf("fichas: loading field definitions: %w", err)
+ }
+ var values []CardValue
+ if err := g.Where("card_id = ?", card.ID).Find(&values).Error; err != nil {
+ return CardView{}, fmt.Errorf("fichas: loading values: %w", err)
+ }
+ byDef := map[int64][]byte{}
+ for _, v := range values {
+ byDef[v.FieldDefID] = v.SealedValue
+ }
+ fields, err := fieldViews(s, defs, byDef)
+ if err != nil {
+ return CardView{}, err
+ }
+
+ entries, err := loadEntries(g, s, card.ID)
+ if err != nil {
+ return CardView{}, err
+ }
+ return CardView{Card: card, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields, Entries: entries}, nil
+}
+
+func loadEntries(g *gorm.DB, s *seal.Sealer, cardID int64) ([]EntryView, error) {
+ var entries []Entry
+ if err := g.Where("card_id = ?", cardID).Order("created_at desc, id desc").Find(&entries).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading entries: %w", err)
+ }
+ if len(entries) == 0 {
+ return nil, nil
+ }
+
+ ids := make([]int64, len(entries))
+ typeIDs := map[int64]bool{}
+ for i, e := range entries {
+ ids[i] = e.ID
+ typeIDs[e.TypeID] = true
+ }
+ var values []EntryValue
+ if err := g.Where("entry_id IN ?", ids).Find(&values).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading entry values: %w", err)
+ }
+ byEntry := map[int64]map[int64][]byte{}
+ for _, v := range values {
+ if byEntry[v.EntryID] == nil {
+ byEntry[v.EntryID] = map[int64][]byte{}
+ }
+ byEntry[v.EntryID][v.FieldDefID] = v.SealedValue
+ }
+
+ defsByType := map[int64][]FieldDef{}
+ labelByType := map[int64]string{}
+ for id := range typeIDs {
+ var defs []FieldDef
+ if err := g.Where("card_type_id = ?", id).Find(&defs).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading entry field definitions: %w", err)
+ }
+ defsByType[id] = defs
+ var ct CardType
+ if err := g.First(&ct, id).Error; err == nil {
+ l, lerr := openLabel(s, ct.SealedLabel)
+ if lerr != nil {
+ return nil, lerr
+ }
+ labelByType[id] = l
+ }
+ }
+
+ out := make([]EntryView, 0, len(entries))
+ for _, e := range entries {
+ fields, err := fieldViews(s, append([]FieldDef(nil), defsByType[e.TypeID]...), byEntry[e.ID])
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, EntryView{Entry: e, TypeLabel: labelByType[e.TypeID], Fields: fields})
+ }
+ return out, nil
+}
+
+// EntryFields returns the blank field set for a new entry of a type, so a
+// form can be rendered without inventing its own list.
+func EntryFields(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string) ([]FieldView, error) {
+ g := db.WithContext(ctx)
+ ct, err := typeByKey(g, typeKey, ScopeEntry)
+ if err != nil {
+ return nil, err
+ }
+ var defs []FieldDef
+ if err := g.Where("card_type_id = ?", ct.ID).Find(&defs).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading field definitions: %w", err)
+ }
+ return fieldViews(s, defs, nil)
+}
+
+// CardFields is EntryFields for a card type.
+func CardFields(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string) ([]FieldView, error) {
+ g := db.WithContext(ctx)
+ ct, err := typeByKey(g, typeKey, ScopeCard)
+ if err != nil {
+ return nil, err
+ }
+ var defs []FieldDef
+ if err := g.Where("card_type_id = ?", ct.ID).Find(&defs).Error; err != nil {
+ return nil, fmt.Errorf("fichas: loading field definitions: %w", err)
+ }
+ return fieldViews(s, defs, nil)
+}
+
+// memberByAddress finds or creates the Member for an address.
+//
+// The address itself never lands in the clear: it is printed for lookup
+// and sealed for display, which is memoria's shape rather than Sheets'.
+// Creating on first sight is right for a per-team instance — a person who
+// can sign in is a member — and it is the one place a Member row is born.
+func memberByAddress(ctx context.Context, db *gorm.DB, s *seal.Sealer, address string) (Member, error) {
+ g := db.WithContext(ctx)
+ print, err := s.Print("f_member", []byte(address))
+ if err != nil {
+ return Member{}, fmt.Errorf("fichas: printing an address: %w", err)
+ }
+
+ var m Member
+ err = g.Where("address_print = ?", print).First(&m).Error
+ if err == nil {
+ return m, nil
+ }
+ if err != gorm.ErrRecordNotFound {
+ return Member{}, fmt.Errorf("fichas: looking up a member: %w", err)
+ }
+
+ sealed, err := s.Seal(seal.PurposeLabel, []byte(address))
+ if err != nil {
+ return Member{}, fmt.Errorf("fichas: sealing an address: %w", err)
+ }
+ m = Member{AddressPrint: print, SealedAddress: sealed}
+ if err := g.Create(&m).Error; err != nil {
+ return Member{}, fmt.Errorf("fichas: creating a member: %w", err)
+ }
+ return m, nil
+}
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index c860d92..a1f32ab 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS)
var pages = map[string]*template.Template{}
func init() {
- for _, name := range []string{"index", "errors"} {
+ for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete"} {
pages[name] = template.Must(
// ui's partials and their helpers first, so every page can
// call one without registering anything. WithIcons points
@@ -40,7 +40,7 @@ func init() {
// switching delivery later needs no template edit.
template.Must(template.New("layout").
Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{"asset": assets.Path}).
+ Funcs(template.FuncMap{"asset": assets.Path, "options": selectOptions}).
ParseFS(ui.Templates(), "*.html")).
// Then the app's own: the shell and this page's body.
ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
@@ -70,3 +70,18 @@ func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
w.WriteHeader(status)
render(w, "errors", map[string]any{"Status": status, "Ref": ref})
}
+
+// selectOptions turns a field's unsealed enum values into what
+// ui's field-select wants, marking the current one selected.
+//
+// A blank first option is always offered: a required-looking select with
+// no empty choice silently submits its first value for a card nobody has
+// set a stage on yet, which writes a value a person never chose.
+func selectOptions(values []string, current string) []map[string]any {
+ out := make([]map[string]any, 0, len(values)+1)
+ out = append(out, map[string]any{"Value": "", "Label": "—", "Selected": current == ""})
+ for _, v := range values {
+ out = append(out, map[string]any{"Value": v, "Label": v, "Selected": v == current})
+ }
+ return out
+}
diff --git a/internal/fichas/templates/card_delete.html b/internal/fichas/templates/card_delete.html
new file mode 100644
index 0000000..fd085f2
--- /dev/null
+++ b/internal/fichas/templates/card_delete.html
@@ -0,0 +1,16 @@
+{{define "content"}}
+{{$c := .Card}}
+{{template "page-header" (dict "Title" "Delete this customer?")}}
+
+{{template "callout" (dict
+ "Tone" "warning"
+ "Title" $c.Title
+ "Body" "The card stops appearing and stops being offered to memoria. Its change log stays, because who deleted it is the question an audit log most needs to answer.")}}
+
+{{template "confirm-form" (dict
+ "Action" (printf "/cards/%d/delete" $c.Card.ID)
+ "Label" "Delete this customer"
+ "Danger" true)}}
+
+<p><a href="/cards/{{$c.Card.ID}}">Keep it</a></p>
+{{end}}
diff --git a/internal/fichas/templates/card_new.html b/internal/fichas/templates/card_new.html
new file mode 100644
index 0000000..5dae001
--- /dev/null
+++ b/internal/fichas/templates/card_new.html
@@ -0,0 +1,20 @@
+{{define "content"}}
+{{template "page-header" (dict "Title" "Add a customer")}}
+
+<form method="post" action="/cards">
+ {{range $i, $f := .Fields}}
+ {{if eq $f.Def.Kind "enum"}}
+ {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
+ "Options" (options $f.Options ""))}}
+ {{else if eq $f.Def.Kind "date"}}
+ {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label)}}
+ {{else if eq $f.Def.Key "f_notes"}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Rows" 4)}}
+ {{else}}
+ {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
+ "Autofocus" (eq $i 0))}}
+ {{end}}
+ {{end}}
+ {{template "form-foot" (dict "SubmitLabel" "Save this customer" "CancelHref" "/")}}
+</form>
+{{end}}
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html
new file mode 100644
index 0000000..72cb59e
--- /dev/null
+++ b/internal/fichas/templates/card_show.html
@@ -0,0 +1,53 @@
+{{define "content"}}
+{{$c := .Card}}
+{{template "page-header" (dict "Title" $c.Title "Sub" $c.TypeLabel)}}
+
+<form method="post" action="/cards/{{$c.Card.ID}}">
+ {{range $f := $c.Fields}}
+ {{if eq $f.Def.Kind "enum"}}
+ {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
+ "Options" (options $f.Options $f.Value))}}
+ {{else if eq $f.Def.Kind "date"}}
+ {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
+ {{else if eq $f.Def.Key "f_notes"}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4)}}
+ {{else}}
+ {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
+ {{end}}
+ {{end}}
+ {{template "form-foot" (dict "SubmitLabel" "Save changes" "CancelHref" "/")}}
+</form>
+
+<h2>Call log</h2>
+
+<form method="post" action="/cards/{{$c.Card.ID}}/entries">
+ {{range $f := .EntryFields}}
+ {{if eq $f.Def.Kind "date"}}
+ {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label)}}
+ {{else if eq $f.Def.Key "f_body"}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Rows" 3)}}
+ {{else}}
+ {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label)}}
+ {{end}}
+ {{end}}
+ {{template "form-foot" (dict "SubmitLabel" "Log this call")}}
+</form>
+
+{{if $c.Entries}}
+<ol rst-stack>
+ {{range $c.Entries}}
+ <li rst-card>
+ {{template "badge" (dict "Label" .TypeLabel)}}
+ <p>{{.Body}}</p>
+ <p rst-muted><time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{.Entry.CreatedAt.Format "2 January 2006"}}</time></p>
+ </li>
+ {{end}}
+</ol>
+{{else}}
+{{template "empty-state" (dict
+ "Title" "No calls logged"
+ "Body" "Every call you log here feeds the summary on this card. The form above is the whole of it.")}}
+{{end}}
+
+<p><a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this customer</a></p>
+{{end}}
diff --git a/internal/fichas/templates/index.html b/internal/fichas/templates/index.html
index dd9abab..bd246e9 100644
--- a/internal/fichas/templates/index.html
+++ b/internal/fichas/templates/index.html
@@ -1,3 +1,32 @@
{{define "content"}}
-<h1>Hello, World — this is a rastrillo app.</h1>
+{{template "page-header" (dict
+ "Title" "Customers"
+ "Sub" "Everything this team is working on, most recently touched first."
+ "ActionHref" "/cards/new"
+ "ActionLabel" "Add a customer"
+ "ActionIcon" "plus")}}
+
+{{if .Anonymous}}{{template "callout" (dict
+ "Tone" "warning"
+ "Title" "This instance cannot name who is writing"
+ "Body" "Sign-in has not landed yet, so nothing can be saved. The event log records who made every change, and it will not record a guess.")}}{{end}}
+
+{{if .Cards}}
+<ul rst-stack>
+ {{range .Cards}}
+ <li rst-card>
+ <h2><a href="/cards/{{.Card.ID}}">{{.Title}}</a></h2>
+ {{with (.Field "f_stage").Value}}{{template "status-pill" (dict "Label" . "Tone" "neutral")}}{{end}}
+ {{with (.Field "f_email").Value}}<p rst-muted>{{.}}</p>{{end}}
+ {{with (.Field "f_next_action").Value}}<p rst-muted>Next action <time datetime="{{.}}" title="{{.}}">{{.}}</time></p>{{end}}
+ </li>
+ {{end}}
+</ul>
+{{else}}
+{{template "empty-state" (dict
+ "Title" "No customers yet"
+ "Body" "A customer card holds what you know about one prospect, and every call you log against them. Start with the one you spoke to most recently."
+ "ActionHref" "/cards/new"
+ "ActionLabel" "Add a customer")}}
+{{end}}
{{end}}
diff --git a/internal/fichastest/gates_test.go b/internal/fichastest/gates_test.go
index 292959c..7f1539b 100644
--- a/internal/fichastest/gates_test.go
+++ b/internal/fichastest/gates_test.go
@@ -85,7 +85,7 @@ func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if _, err := fichas.App(d, testOrigin, testSealer(t), logger); err != nil {
+ if _, err := fichas.App(d, testConfig(t), logger); err != nil {
t.Fatalf("App: %v", err)
}
diff --git a/internal/fichastest/harness_test.go b/internal/fichastest/harness_test.go
index f8c9385..4a19d51 100644
--- a/internal/fichastest/harness_test.go
+++ b/internal/fichastest/harness_test.go
@@ -7,6 +7,7 @@
package fichastest
import (
+ "encoding/base64"
"io"
"log/slog"
"net/http"
@@ -24,23 +25,50 @@ import (
// testOrigin is the origin newApp wires and post presents as its
// same-origin evidence — the pair a browser form submission would be.
-const testOrigin = "http://app.test"
+// testOrigin is loopback so the local-development member is admitted;
+// LoadConfig refuses it on any other origin.
+const testOrigin = "http://127.0.0.1:8824"
// testSealer is a fixed DEK. Fixed rather than random so that a failure
// is reproducible; it never leaves the test binary.
func testSealer(t *testing.T) *seal.Sealer {
t.Helper()
- k := make([]byte, 32)
- for i := range k {
- k[i] = 0x7f
- }
- s, err := seal.New(k)
+ s, err := seal.New(testKey())
if err != nil {
t.Fatalf("seal.New: %v", err)
}
return s
}
+// testConfig wires the app exactly as main.go does, with a local-dev
+// member so the write paths have somebody to attribute changes to.
+func testConfig(t *testing.T) fichas.Config {
+ t.Helper()
+ c, err := fichas.LoadConfig(func(k string) string {
+ switch k {
+ case "FICHAS_DEK":
+ return base64.StdEncoding.EncodeToString(testKey())
+ case "FICHAS_ORIGIN":
+ return testOrigin
+ case "FICHAS_DEV_MEMBER":
+ return "ana@example.com"
+ }
+ return ""
+ })
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+ return c
+}
+
+func testKey() []byte {
+ k := make([]byte, 32)
+ for i := range k {
+ k[i] = 0x7f
+ }
+ return k
+}
+
// newApp builds the whole app per test over a fresh temp database,
// exactly as main.go does.
func newApp(t *testing.T) http.Handler {
@@ -51,7 +79,7 @@ func newApp(t *testing.T) http.Handler {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := fichas.App(d, testOrigin, testSealer(t), logger)
+ mux, err := fichas.App(d, testConfig(t), logger)
if err != nil {
t.Fatalf("App: %v", err)
}
diff --git a/internal/fichastest/index_test.go b/internal/fichastest/index_test.go
index 54adae5..a74105e 100644
--- a/internal/fichastest/index_test.go
+++ b/internal/fichastest/index_test.go
@@ -19,8 +19,14 @@ func TestIndexRenders(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("GET /: status %d, want 200", rec.Code)
}
- if !strings.Contains(rec.Body.String(), "this is a rastrillo app") {
- t.Errorf("GET /: page is missing its heading:\n%s", rec.Body.String())
+ body := rec.Body.String()
+ if !strings.Contains(body, "Customers") {
+ t.Errorf("GET /: page is missing its heading:\n%s", body)
+ }
+ // An empty state that says what the screen is FOR, not merely that
+ // it is empty (AGENTS.md).
+ if !strings.Contains(body, "No customers yet") {
+ t.Errorf("GET /: no empty state on a fresh instance:\n%s", body)
}
}
diff --git a/internal/fichastest/surface_test.go b/internal/fichastest/surface_test.go
new file mode 100644
index 0000000..b0f1d12
--- /dev/null
+++ b/internal/fichastest/surface_test.go
@@ -0,0 +1,138 @@
+package fichastest
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+var cardHref = regexp.MustCompile(`/cards/(\d+)"`)
+
+// createACustomer walks the real form flow and returns the new card's path.
+func createACustomer(t *testing.T, h http.Handler, name string) string {
+ t.Helper()
+ rec := post(t, h, "/cards", url.Values{
+ "f_name": {name},
+ "f_email": {"ana@example.com"},
+ "f_stage": {"Lead"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /cards: status %d, want 303\n%s", rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/cards/") {
+ t.Fatalf("POST /cards redirected to %q", loc)
+ }
+ return loc
+}
+
+func TestCreateShowAndLogACall(t *testing.T) {
+ h := newApp(t)
+
+ path := createACustomer(t, h, "Cnoc Teoranta")
+
+ rec := get(t, h, path)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d", path, rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Cnoc Teoranta") {
+ t.Errorf("the card does not show its own name:\n%s", body)
+ }
+ if !strings.Contains(body, "No calls logged") {
+ t.Error("a card with no entries has no empty state for them")
+ }
+
+ rec = post(t, h, path+"/entries", url.Values{
+ "f_body": {"talked about the renewal"},
+ "f_occurred_at": {"2026-09-21"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST entries: status %d\n%s", rec.Code, rec.Body.String())
+ }
+
+ body = get(t, h, path).Body.String()
+ if !strings.Contains(body, "talked about the renewal") {
+ t.Errorf("the logged call does not appear on the card:\n%s", body)
+ }
+
+ // And it is on the list, by name.
+ if !strings.Contains(get(t, h, "/").Body.String(), "Cnoc Teoranta") {
+ t.Error("a created customer does not appear on the index")
+ }
+}
+
+func TestUpdateACustomer(t *testing.T) {
+ h := newApp(t)
+ path := createACustomer(t, h, "Cnoc Teoranta")
+
+ rec := post(t, h, path, url.Values{
+ "f_name": {"Cnoc Teoranta"},
+ "f_stage": {"Proposal"},
+ "f_notes": {"renewal is due in March"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST %s: status %d\n%s", path, rec.Code, rec.Body.String())
+ }
+ body := get(t, h, path).Body.String()
+ if !strings.Contains(body, "renewal is due in March") {
+ t.Errorf("the update did not take:\n%s", body)
+ }
+}
+
+// A destructive action gets its own URL and a confirm page, never a modal
+// and never a bare link that acts (AGENTS.md).
+func TestDeleteIsConfirmedOnItsOwnPage(t *testing.T) {
+ h := newApp(t)
+ path := createACustomer(t, h, "Cnoc Teoranta")
+
+ rec := get(t, h, path+"/delete")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s/delete: status %d", path, rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Delete this customer") {
+ t.Error("the confirm page does not say what it will do")
+ }
+
+ if rec := post(t, h, path+"/delete", url.Values{}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST delete: status %d", rec.Code)
+ }
+ if got := get(t, h, path).Code; got != http.StatusNotFound {
+ t.Errorf("a deleted card still renders: status %d", got)
+ }
+ if strings.Contains(get(t, h, "/").Body.String(), "Cnoc Teoranta") {
+ t.Error("a deleted card is still on the index")
+ }
+}
+
+// A card that is not there is 404, never 403 — the suite's rule that a row
+// you cannot have is a row that does not exist.
+func TestAMissingCardIs404(t *testing.T) {
+ h := newApp(t)
+ for _, p := range []string{"/cards/9999", "/cards/nonsense", "/cards/0", "/cards/-1"} {
+ if got := get(t, h, p).Code; got != http.StatusNotFound {
+ t.Errorf("GET %s: status %d, want 404", p, got)
+ }
+ }
+}
+
+// Everything works with scripts off, so every mutation is a form POST and
+// no page depends on a script to be usable.
+func TestNoPageNeedsJavaScript(t *testing.T) {
+ h := newApp(t)
+ path := createACustomer(t, h, "Cnoc Teoranta")
+ for _, p := range []string{"/", "/cards/new", path, path + "/delete"} {
+ body := get(t, h, p).Body.String()
+ if strings.Contains(body, "<noscript") {
+ continue
+ }
+ if strings.Contains(body, "onclick=") || strings.Contains(body, "javascript:") {
+ t.Errorf("%s carries inline script behaviour", p)
+ }
+ if strings.Contains(body, "<form") && !strings.Contains(body, "method=\"post\"") {
+ t.Errorf("%s has a form that does not POST", p)
+ }
+ }
+}