Table Export Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A shared “choose your columns” export: a server-side column catalogue per export kind, user-saved private/shareable presets, CSV and XLSX output, one ExportDialog in the web app — proven end to end on the expense-claims export.
Architecture: platform/tableexport owns the types (Column, Choice, Kind), the kind registry, Resolve (a user’s choice against a catalogue) and Write (rows to CSV/XLSX through reporting/render). platform/exportpreset stores presets (a copy of the savedreport pattern). A new modules/exports serves the catalogue and preset routes. Row sourcing stays in each owning module (modules may not import each other), so each module’s existing export route takes columns, headings and format query parameters. Claims is the first kind; journal entries, petty cash and parties follow in a later plan.
Tech Stack: Go (gin, pgx, goose, oapi-codegen), React/TypeScript (react-query, openapi-fetch), reporting/render.
Spec: ../specs/2026-10-09-table-export-design.md. Deviation from the spec, to be reflected in it: the spec’s single POST /v1/exports/{kind} becomes GET <owning module's existing export route>?columns=…&headings=…&format=…, because row sources need module internals (claim visibility rules live in modules/expenses) and modules/ may not import modules/. Catalogue and presets stay generic.
Global Constraints
Section titled “Global Constraints”- Dependencies point
modules/→platform/→kernel/only; a module never imports another module. - A float never touches money: money cells are
money.Amountstrings (render.Money); XLSX holds the decimal. - Every table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, policy namedorg_isolation. - Every request runs in a transaction, reads included (
InTx/InReadOnlyTx). - Endpoints are declared in
api/openapi.yamlfirst; runmake generateafter editing it. - Every user-facing string is en/ms/zh; ms/zh unreviewed strings are noted in ROADMAP.
- Rows capped at 20,000 per export (
exportMaxClaimsprecedent); over the cap → refuse, never truncate silently. - Migration numbers: new range
1190–1199(add tomigrationRanges). - Execute in a worktree cut from a base that contains the owner’s in-flight claims work (
web/src/features/claims/*,internal/modules/expenses/claim_handlers.go) — it touches the same files. - Absence assertions need a presence assertion first (AGENTS.md); watch each new guard fail once.
- While iterating test only touched packages; full
go test ./... -p 1once before the last commit.
Task 1: Plain tables in the CSV and XLSX writers
Section titled “Task 1: Plain tables in the CSV and XLSX writers”The writers print a title row, key/value lines and a blank row above the header. A data export needs its header on row 1 (spreadsheet tools, D365 and re-import all expect that).
Files:
- Modify:
internal/platform/reporting/render/render.go(Meta) - Modify:
internal/platform/reporting/render/csv.go(Begin) - Modify:
internal/platform/reporting/render/xlsx.go(Begin) - Test:
internal/platform/reporting/render/plain_test.go
Interfaces:
-
Produces:
render.Meta.Plain bool— when true, CSV and XLSX write only the header row and data rows. -
Step 1: Failing test
package render_test
import ( "archive/zip" "bytes" "io" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/reporting/render")
func build(t *testing.T, f render.Format, plain bool) []byte { t.Helper() w, _, _, err := render.New(f) require.NoError(t, err) require.NoError(t, w.Begin(render.Meta{Title: "Claims", Plain: plain}, []render.Column{{Title: "Ref"}, {Title: "Amount", Numeric: true}})) require.NoError(t, w.Row([]render.Cell{render.Text("C-1"), render.Money(money.MustParse("12.5"))})) out, err := w.Finish() require.NoError(t, err) return out}
func TestPlainCSVStartsWithTheHeader(t *testing.T) { // PRESENCE: without Plain the title row is there. require.Contains(t, string(build(t, render.FormatCSV, false)), "Report,Claims") got := string(build(t, render.FormatCSV, true)) require.Equal(t, "\xef\xbb\xbfRef,Amount\r\nC-1,12.5000\r\n", got)}
func TestPlainXLSXHasNoTitleRow(t *testing.T) { sheet := func(b []byte) string { zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) require.NoError(t, err) for _, f := range zr.File { if f.Name == "xl/worksheets/sheet1.xml" { rc, _ := f.Open() defer rc.Close() s, _ := io.ReadAll(rc) return string(s) } } t.Fatal("no sheet1") return "" } require.Contains(t, sheet(build(t, render.FormatXLSX, false)), "Claims") // presence require.NotContains(t, sheet(build(t, render.FormatXLSX, true)), "Claims") require.Contains(t, sheet(build(t, render.FormatXLSX, true)), `<row r="1">`)}(money.MustParse — if it does not exist, use a, err := money.Parse("12.5"); require.NoError(t, err).)
-
Step 2: Run, expect FAIL —
go test ./internal/platform/reporting/render/ -run Plain -count=1→ compile errorunknown field Plain. -
Step 3: Implement
render.go, in Meta add:
// Plain writes only the header row and the data rows (CSV and XLSX), for a table another tool // reads: no title, no key/value lines, no blank row. Plain boolcsv.go Begin: wrap the title/fields/blank-row writes:
if !m.Plain { if err := c.w.Write([]string{"Report", safeText(m.Title)}); err != nil { return err } for _, f := range m.Fields { if err := c.w.Write([]string{safeText(f[0]), safeText(f[1])}); err != nil { return err } } if err := c.w.Write([]string{""}); err != nil { return err } }xlsx.go Begin: wrap the title write, the fields loop and the blank-row write in if !m.Plain { … } (keep the header write after it unconditional).
-
Step 4: Run, expect PASS — same command, plus
go test ./internal/platform/reporting/... -count=1(existing report output unchanged). -
Step 5: Commit
git add internal/platform/reporting/rendergit commit -m "render: Meta.Plain writes a bare header-and-rows table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 2: platform/tableexport — catalogue, choice, writer
Section titled “Task 2: platform/tableexport — catalogue, choice, writer”Files:
- Create:
internal/platform/tableexport/tableexport.go - Test:
internal/platform/tableexport/tableexport_test.go
Interfaces:
- Consumes:
render.New,render.Meta{Plain:true},render.Text,render.Money,money.Parse. - Produces:
type Type stringconst (TypeText Type = "text"; TypeMoney Type = "money")type Column struct { Key, Label string; Type Type; Default bool } // Label = built-in English headingtype Choice struct { Key string `json:"key"`; Heading string `json:"heading,omitempty"` }type Kind struct { Key string Permission string // the read key of the data; also gates the catalogue and presets of this kind Columns func(ctx context.Context, tx pgx.Tx) ([]Column, error)}func Register(k Kind) // panics on duplicate key (init-time programming error)func Lookup(key string) (Kind, bool)func Kinds() []Kind // sorted by keyfunc Defaults(cat []Column) []Choice // the Default columns, catalogue orderfunc Resolve(cat []Column, chosen []Choice) ([]Column, error) // Label replaced by the chosen headingfunc Write(f render.Format, cols []Column, rows []map[string]string) (body []byte, contentType, ext string, err error)var ErrNoColumns, ErrUnknownColumn, ErrDuplicateColumn, ErrHeading, ErrTooManyRows errorconst MaxRows = 20000func ProblemCode(err error) (code string, ok bool) // errors.go: the 422 code for a refusal above- Step 1: Failing tests
package tableexport_test
import ( "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/reporting/render" "github.com/mcom/erp/internal/platform/tableexport")
var cat = []tableexport.Column{ {Key: "ref", Label: "Claim no.", Type: tableexport.TypeText, Default: true}, {Key: "amount", Label: "Amount", Type: tableexport.TypeMoney, Default: true}, {Key: "remarks", Label: "Remarks", Type: tableexport.TypeText},}
func TestDefaultsAreTheDefaultColumnsInCatalogueOrder(t *testing.T) { require.Equal(t, []tableexport.Choice{{Key: "ref"}, {Key: "amount"}}, tableexport.Defaults(cat))}
func TestResolveKeepsChosenOrderAndRenamesHeadings(t *testing.T) { cols, err := tableexport.Resolve(cat, []tableexport.Choice{{Key: "amount", Heading: "Jumlah"}, {Key: "ref"}}) require.NoError(t, err) require.Equal(t, "Jumlah", cols[0].Label) require.Equal(t, "Claim no.", cols[1].Label)}
func TestResolveRefusals(t *testing.T) { _, err := tableexport.Resolve(cat, nil) require.ErrorIs(t, err, tableexport.ErrNoColumns) _, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "nope"}}) require.ErrorIs(t, err, tableexport.ErrUnknownColumn) _, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "ref"}, {Key: "ref"}}) require.ErrorIs(t, err, tableexport.ErrDuplicateColumn) _, err = tableexport.Resolve(cat, []tableexport.Choice{{Key: "ref", Heading: string(make([]byte, 101))}}) require.ErrorIs(t, err, tableexport.ErrHeading)}
func TestWriteMoneyIsExactAndTextIsGuarded(t *testing.T) { cols, err := tableexport.Resolve(cat, tableexport.Defaults(cat)) require.NoError(t, err) body, ct, ext, err := tableexport.Write(render.FormatCSV, cols, []map[string]string{ {"ref": "=cmd", "amount": "-1234.5"}, {"ref": "C-2", "amount": ""}, }) require.NoError(t, err) require.Equal(t, "csv", ext) require.Contains(t, ct, "text/csv") require.Equal(t, "\xef\xbb\xbfClaim no.,Amount\r\n'=cmd,-1234.5000\r\nC-2,\r\n", string(body))}
func TestWriteXLSX(t *testing.T) { cols, _ := tableexport.Resolve(cat, tableexport.Defaults(cat)) body, _, ext, err := tableexport.Write(render.FormatXLSX, cols, []map[string]string{{"ref": "C-1", "amount": "12.5"}}) require.NoError(t, err) require.Equal(t, "xlsx", ext) require.Greater(t, len(body), 100)}
func TestWriteRefusesPDFAndTooManyRows(t *testing.T) { cols, _ := tableexport.Resolve(cat, tableexport.Defaults(cat)) _, _, _, err := tableexport.Write(render.FormatPDF, cols, nil) require.Error(t, err) _, _, _, err = tableexport.Write(render.FormatCSV, cols, make([]map[string]string, tableexport.MaxRows+1)) require.ErrorIs(t, err, tableexport.ErrTooManyRows)}-
Step 2: Run, expect FAIL (package missing):
go test ./internal/platform/tableexport/ -count=1. -
Step 3: Implement
tableexport.go:
// Package tableexport is the shared half of "export this list as a spreadsheet": a column catalogue// per export kind, a user's choice of columns against it, and the CSV/XLSX writer. It reads no// records: each owning module supplies rows, because what a caller may see is that module's rule.package tableexport
import ( "context" "errors" "fmt" "sort" "unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/reporting/render")
// MaxRows bounds one export; the file is built in memory.// ponytail: in-memory, 20,000 rows; stream or run as a background job if it must grow.const MaxRows = 20000
type Type string
const ( TypeText Type = "text" TypeMoney Type = "money")
// Column is one column an export kind can carry. Label is the built-in English heading.type Column struct { Key string Label string Type Type Default bool}
// Choice is a user's pick: a catalogue key and, optionally, their own heading.type Choice struct { Key string `json:"key"` Heading string `json:"heading,omitempty"`}
// Kind is one thing that can be exported. Permission is the read key of its data; the catalogue and// presets of the kind need it too, so choosing columns never widens what a caller can see.type Kind struct { Key string Permission string Columns func(ctx context.Context, tx pgx.Tx) ([]Column, error)}
var ( ErrNoColumns = errors.New("tableexport: choose at least one column") ErrUnknownColumn = errors.New("tableexport: no such column") ErrDuplicateColumn = errors.New("tableexport: a column is chosen twice") ErrHeading = errors.New("tableexport: a heading is at most 100 characters") ErrTooManyRows = errors.New("tableexport: too many rows; narrow the filters"))
var kinds = map[string]Kind{}
// Register adds a kind; called from init(), so a duplicate key is a programming error.func Register(k Kind) { if _, dup := kinds[k.Key]; dup { panic("tableexport: kind registered twice: " + k.Key) } kinds[k.Key] = k}
func Lookup(key string) (Kind, bool) { k, ok := kinds[key]; return k, ok }
func Kinds() []Kind { out := make([]Kind, 0, len(kinds)) for _, k := range kinds { out = append(out, k) } sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out}
// Defaults is the catalogue's default columns in catalogue order.func Defaults(cat []Column) []Choice { var out []Choice for _, c := range cat { if c.Default { out = append(out, Choice{Key: c.Key}) } } return out}
// Resolve checks chosen against the catalogue and returns the columns in the chosen order, each// with the chosen heading (or its built-in one).func Resolve(cat []Column, chosen []Choice) ([]Column, error) { if len(chosen) == 0 { return nil, ErrNoColumns } byKey := make(map[string]Column, len(cat)) for _, c := range cat { byKey[c.Key] = c } seen := map[string]bool{} out := make([]Column, 0, len(chosen)) for _, ch := range chosen { c, ok := byKey[ch.Key] if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownColumn, ch.Key) } if seen[ch.Key] { return nil, fmt.Errorf("%w: %s", ErrDuplicateColumn, ch.Key) } seen[ch.Key] = true if utf8.RuneCountInString(ch.Heading) > 100 { return nil, ErrHeading } if ch.Heading != "" { c.Label = ch.Heading } out = append(out, c) } return out, nil}
// Write renders rows (cell text by column key) as CSV or XLSX under cols' headings. A money cell// that is not a decimal is written as text, never converted through a float.func Write(f render.Format, cols []Column, rows []map[string]string) ([]byte, string, string, error) { if f != render.FormatCSV && f != render.FormatXLSX { return nil, "", "", fmt.Errorf("tableexport: format %q is not a table format", f) } if len(rows) > MaxRows { return nil, "", "", ErrTooManyRows } w, contentType, ext, err := render.NewKind(f, render.KindExport) if err != nil { return nil, "", "", err } rc := make([]render.Column, len(cols)) for i, c := range cols { rc[i] = render.Column{Title: c.Label, Numeric: c.Type == TypeMoney, Weight: 1} } if err := w.Begin(render.Meta{Plain: true}, rc); err != nil { return nil, "", "", err } for _, r := range rows { cells := make([]render.Cell, len(cols)) for i, c := range cols { v := r[c.Key] if c.Type == TypeMoney && v != "" { if a, err := money.Parse(v); err == nil { cells[i] = render.Money(a) continue } } cells[i] = render.Text(v) } if err := w.Row(cells); err != nil { return nil, "", "", err } } body, err := w.Finish() return body, contentType, ext, err}Add ErrTooManyRows is already declared above. If render.KindExport is not exported under that name, grep -n "Kind" internal/platform/reporting/render/*.go | head and use the actual constant.
Also create internal/platform/tableexport/errors.go (used by Tasks 4 and 5, with a one-line test per code):
package tableexport
import "errors"
// ProblemCode is the API problem code for a refusal of a column choice or an export size.func ProblemCode(err error) (string, bool) { switch { case errors.Is(err, ErrNoColumns): return "export_no_columns", true case errors.Is(err, ErrUnknownColumn): return "export_unknown_column", true case errors.Is(err, ErrDuplicateColumn): return "export_duplicate_column", true case errors.Is(err, ErrHeading): return "export_heading", true case errors.Is(err, ErrTooManyRows): return "export_too_many_rows", true } return "", false}-
Step 4: Run, expect PASS:
go test ./internal/platform/tableexport/ -count=1. If the CSV money expectation differs (-1234.5000) becauseAmount.String()formats differently, correct the test to the real output — do not change the writer. -
Step 5: Watch it fail once — remove the
safeTextapostrophe expectation by editing the test’s first row to"ref": "x"? No: instead temporarily makeResolveskip the duplicate check, runTestResolveRefusals, read the failure, restore. -
Step 6: Commit
git add internal/platform/tableexport && git commit -m "tableexport: column catalogue, choice and CSV/XLSX writer"(with the Co-Authored-By trailer).
Task 3: Presets store (platform/exportpreset, migration)
Section titled “Task 3: Presets store (platform/exportpreset, migration)”Files:
- Create:
db/migrations/01190_export_preset.sql - Modify:
internal/kernel/db/migrationranges_test.go(add{1190, 1199, "Table export presets (docs/superpowers/specs/2026-10-09-table-export-design.md)"},) - Modify:
internal/kernel/db/retention_test.go(add"export_preset"with a comment// 01190: a saved column choice, a convenience; what an export carried is not kept.next to"saved_report") - Create:
internal/platform/exportpreset/exportpreset.go - Test:
internal/platform/exportpreset/exportpreset_test.go
Interfaces:
- Consumes:
tableexport.Choice. - Produces:
type Preset struct { ID, OwnerID uuid.UUID; Kind, Name string; Columns []tableexport.Choice; Shared bool; Revision int; CreatedAt, UpdatedAt time.Time }func List(ctx, tx pgx.Tx, viewer uuid.UUID, kind string) ([]Preset, error) // own + shared, by namefunc Create(ctx, tx, c clock.Clock, owner uuid.UUID, kind, name string, cols []tableexport.Choice, shared bool) (Preset, error)func Update(ctx, tx, c clock.Clock, id, by uuid.UUID, expectedRevision int, name string, cols []tableexport.Choice, shared bool) (Preset, error)func Delete(ctx, tx, id, by uuid.UUID) errorvar ErrNotFound, ErrNotOwner, ErrName, ErrDuplicate, ErrColumns errortype RevisionMismatchError struct{ Current int }Validation of columns against a catalogue is the caller’s (module’s) job via tableexport.Resolve; the store only requires 1–200 choices.
- Step 1: Migration
-- +goose Up-- A user's saved column choice for an export kind, private to its owner unless shared with the-- organization (docs/superpowers/specs/2026-10-09-table-export-design.md). A saved query, not a-- record: its owner edits and deletes it freely.CREATE TABLE export_preset ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), owner_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT export_preset_kind_shape CHECK (kind ~ '^[a-z][a-z0-9_]{0,59}$'), name TEXT NOT NULL CONSTRAINT export_preset_name_shape CHECK (btrim(name) <> '' AND length(name) <= 100), columns JSONB NOT NULL CONSTRAINT export_preset_columns_array CHECK (jsonb_typeof(columns) = 'array'), shared BOOLEAN NOT NULL DEFAULT false, revision INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT export_preset_name_once UNIQUE (org_id, owner_id, kind, name), CONSTRAINT export_preset_owner_fk FOREIGN KEY (org_id, owner_id) REFERENCES organization_member (org_id, id));
ALTER TABLE export_preset ENABLE ROW LEVEL SECURITY;ALTER TABLE export_preset FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON export_preset USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- +goose DownDROP TABLE export_preset;Run make migrate (or the repo’s migrate command) against the dev DB only if the repo’s tests do not apply migrations themselves — testdb.Start applies them.
- Step 2: Failing test (
exportpreset_test.go) — use the same world helper other platform tests use for an org + two members:expensestest.New(t)givesEnvwithDB,Ctx,Clock,Org,Owner; for a second member find a helper withgrep -n "func.*Member" internal/platform/expenses/expensestest/*.go. Tests to write, each asserting presence before absence:
func TestPrivatePresetIsInvisibleToOthersUntilShared(t *testing.T) { // owner creates {kind:"claims", name:"mine"} not shared // presence: List(owner) has it // absence: List(other) is empty // Update shared=true (expectedRevision 1) -> List(other) has it // other's Update -> ErrNotOwner; other's Delete -> ErrNotOwner // after unsharing, other's Update -> ErrNotFound (cannot see it)}func TestDuplicateNameForTheSameOwnerAndKind(t *testing.T) { /* second Create same name -> ErrDuplicate; same name other kind OK */ }func TestStaleRevisionIsRefused(t *testing.T) { /* Update with revision 99 -> *RevisionMismatchError{Current:1} */ }func TestNameAndColumnsValidation(t *testing.T) { /* "" -> ErrName; 101 chars -> ErrName; zero choices -> ErrColumns */ }Write these out fully against the helpers found; each calls the store inside env.DB.InTx(env.Ctx, …).
-
Step 3: Run, expect FAIL (package missing).
-
Step 4: Implement
exportpreset.goby copyinginternal/platform/savedreport/savedreport.goand changing: tableexport_preset; addkindcolumn tocolumnsconst,Preset, scan, INSERT (kindis$4, shift others) and theListfilterAND kind = $3;Definition export.Params→Columns []tableexport.Choice(pgx marshals a slice to JSONB);check(name, cols)returnsErrNamefor blank/over-100-rune names andErrColumnsforlen(cols)==0 || len(cols)>200; constraint name inmapWriteisexport_preset_name_once; error prefixexportpreset:;Updatedoes not changekind(a preset cannot move between kinds). KeeplockOwned,RevisionMismatchError,Deleteidentical. (Errors and messages:ErrColumns = errors.New("exportpreset: a preset needs between 1 and 200 columns").) -
Step 5: Run, expect PASS:
go test ./internal/platform/exportpreset/ ./internal/kernel/db/ -run 'Preset|Migration|Retention|RLS|Tenant' -count=1. The every-table tenant and retention tests must pass with the new table (they fail if the policy is wrong or the table is unlisted). -
Step 6: Commit the migration, the two test-list edits and the package.
Task 4: modules/exports — permission, catalogue and preset routes
Section titled “Task 4: modules/exports — permission, catalogue and preset routes”Files:
- Create:
internal/modules/exports/permissions.go,routes.go,handlers.go,handlers_test.go - Modify:
internal/app/app.go(Deps fieldExports exports.Deps;exports.Register(api, deps.Exports)afterreports.Register) - Modify:
cmd/api/main.go(Exports: exports.Deps{DB: database, Clock: <the clock reports uses>},next toActivity:) - Modify:
api/openapi.yaml(paths and schemas below) - Modify:
docs/superpowers/specs/2026-10-09-table-export-design.md(API section: reflect the deviation)
Interfaces:
-
Consumes:
tableexport.Lookup/Resolve,exportpreset.*,member.Can(ctx, tx, memberID, key) (bool, error),audit.ActorFromContext(ctx)(.MemberID),httpx.Protected(g, perms, method, path, permission, handler),apperr.New(kind, msg).With("code", …). -
Produces routes (all
exports.use; each also needs the kind’s ownPermission, checked in the handler, 403export_kind_forbidden; unknown kind 404export_kind_not_found):GET /v1/exports/kinds/{kind}/columns→{ "columns": [{key,label,type,default}] }GET /v1/exports/presets?kind=→{ "presets": [ExportPreset] }POST /v1/exports/presets,PUT /v1/exports/presets/{id},DELETE /v1/exports/presets/{id}
-
Step 1: permissions.go
package exports
import "github.com/mcom/erp/internal/platform/identity/member"
// PermissionUse gates choosing columns and saving presets. It adds nothing to what a caller can// read: each kind's own read key is checked on top (handlers.go).const PermissionUse = "exports.use"
func init() { member.RegisterPermission(PermissionUse, "Choose the columns of an export and save column presets")}Then go run ./tools/vocabgen (part of make generate) so vocab.gen.go lists it; commit the generated diff.
- Step 2: routes.go — copy
internal/modules/activity/routes.go’sRegister,permissionLoaderverbatim with packageexports,Deps{DB *db.DB; Clock clock.Clock}, and:
httpx.Protected(g, perms, "GET", "/exports/kinds/:kind/columns", PermissionUse, h.columns) httpx.Protected(g, perms, "GET", "/exports/presets", PermissionUse, h.listPresets) httpx.Protected(g, perms, "POST", "/exports/presets", PermissionUse, h.createPreset) httpx.Protected(g, perms, "PUT", "/exports/presets/:id", PermissionUse, h.updatePreset) httpx.Protected(g, perms, "DELETE", "/exports/presets/:id", PermissionUse, h.deletePreset)-
Step 3: handlers.go — model on
internal/modules/reports/custom_handlers.go(saved report handlers, quoted in the repo) with these specifics:decodeStrict: copy the helper frominternal/modules/reports/http.go:132(disallow unknown fields, 400 on empty body).kindFor(ctx, tx, c, name) (tableexport.Kind, error):Lookup→ elseapperr.New(apperr.KindNotFound, "no such export").With("code","export_kind_not_found"); thenmember.Can(ctx, tx, by.MemberID, k.Permission)false →apperr.New(apperr.KindForbidden, "you cannot read this data").With("code","export_kind_forbidden").columns: inInReadOnlyTx,kindFor, thenk.Columns(ctx, tx); DTO{key,label,type,default}.createPreset/updatePreset: body{kind, name, columns:[{key,heading}], shared, revision}; inInTx:kindFor,k.Columnsthentableexport.Resolve(cat, body.Columns)(a bad key → 422export_unknown_column, none chosen → 422export_no_columns, duplicate → 422export_duplicate_column, heading → 422export_heading), thenexportpreset.Create/Update.updatePresetreads the preset’s kind from the store (a preset cannot change kind; ignore body kind).listPresets: querykindrequired;kindFor;exportpreset.List(ctx, tx, by.MemberID, kind).mapErrfor preset refusals:ErrNotFound404export_preset_not_found;ErrNotOwner403export_preset_not_owner;ErrName422export_preset_name;ErrColumns422export_no_columns;ErrDuplicate409export_preset_duplicate;*RevisionMismatchError409revision_mismatch.- DTO
exportPresetDTO{ID, OwnerID, Kind, Name, Columns []tableexport.Choice, Shared, Revision, UpdatedAt}.
-
Step 4: openapi.yaml — add the five operations (
getExportColumns,listExportPresets,createExportPreset,updateExportPreset,deleteExportPreset) modelled onlistSavedReports…deleteSavedReport(api/openapi.yaml ~7478–7570), schemasExportColumn {key,label,type: text|money,default},ExportChoice {key, heading?},ExportPreset,ExportPresetInput {kind,name,columns,shared,revision?}. Thenmake generateandgo build ./.... -
Step 5: Tests (
handlers_test.go): copynewEnvfrominternal/modules/activity/handlers_test.go(router + signer + token minting), changeRegistertoexports.Register, register a throwaway kind in the test’sinit():
func init() { tableexport.Register(tableexport.Kind{Key: "testkind", Permission: "exports.testread", Columns: func(ctx context.Context, tx pgx.Tx) ([]tableexport.Column, error) { return []tableexport.Column{{Key: "a", Label: "A", Type: tableexport.TypeText, Default: true}}, nil }}) member.RegisterPermission("exports.testread", "test")}Cases (grant keys through the same role helper the activity test uses to give activity.read):
- holder of
exports.use+exports.testreadgets the catalogue (200, one column) — presence; - holder of
exports.useonly gets 403export_kind_forbidden— absence, meaningful because (1) passed; - holder of neither gets 403 from
Authorize; - create → list → update (stale revision 409) → delete round trip; a second member sees a shared preset, not a private one; unknown column key → 422
export_unknown_column.
-
Step 6: Drift tests — run
go test ./internal/app/... -count=1(route/permission/openapi drift: every route behindhttpx.Authorize, every env var, generated code matches). Fix what they report; do not editapi/public_routes.txt(these routes are authenticated). -
Step 7: Commit (
git add internal/modules/exports internal/app cmd/api api/openapi.yaml internal/gen internal/kernel/vocab docs/...). Update ROADMAP sub-bullets only for the owner-uncommitted caveat (see Task 6).
Task 5: Claims export on the shared mechanism
Section titled “Task 5: Claims export on the shared mechanism”Files:
- Modify:
internal/modules/expenses/export_handlers.go - Create:
internal/modules/expenses/export_kind.go(theclaimskind) - Modify:
internal/platform/printing/exportlayout.go(only if a helper is needed; see Step 3) - Modify:
api/openapi.yaml(exportExpenseClaims: addcolumns,headings,format; response content types) - Test:
internal/modules/expenses/export_handlers_test.go
Interfaces:
-
Consumes:
printing.ClaimExportCatalog,printing.GetClaimExport(ctx, tx) (StoredExport, error),platformexpenses.ExportLines(values by catalogue key),tableexport.*. -
Produces: kind
claims,Permission: "expenses.claim.read". Catalogue =ClaimExportCatalogmapped totableexport.ColumnwithType: TypeMoneyfor keysamount,base_amount,tax_amount, else text;Default= the organization’s layout (enabled columns ofGetClaimExport), so existing behaviour is the default selection. -
New query parameters on
GET /v1/expenses/claims/export:columns(comma-separated keys, order kept; absent → the organization’s layout, today’s behaviour),headings(comma-separated, same length ascolumns, empty entries keep the default; urlencoded; commas inside a heading are not supported — document it),format(csvdefault |xlsx). -
Step 1: Failing tests in
export_handlers_test.go(reuse the file’s existing fixture that creates claims and reads the CSV):- Existing behaviour holds: the existing tests keep passing with no new parameters (run first, they must be green before and after — except Step 3’s money note).
?columns=reference,amount→ header exactlyClaim no.,Amount(headings from the catalogue’s labels / org layout labels), two columns per row.?columns=amount,reference&headings=Jumlah→ headerJumlah,Claim no..?format=xlsx→Content-Typeis the XLSX type and the body starts withPK.?columns=nope→ 422 problem with codeexport_unknown_column.- A caller without
expenses.claim.read_allaskingscope=allstill gets 403 — present-then-absent: first assert the same request by a holder returns rows.
-
Step 2: Run, expect FAIL (
columnsignored). -
Step 3: Implement
export_kind.go:
package expenses
import ( "context"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/printing" "github.com/mcom/erp/internal/platform/tableexport")
const kindClaims = "claims"
var claimMoney = map[string]bool{"amount": true, "base_amount": true, "tax_amount": true}
func init() { tableexport.Register(tableexport.Kind{Key: kindClaims, Permission: PermissionClaimRead, Columns: claimColumns})}
// claimColumns is the catalogue; the organization's export layout decides which are on by default// and what they are called, so the Document layouts screen keeps working as the default selection.func claimColumns(ctx context.Context, tx pgx.Tx) ([]tableexport.Column, error) { layout, err := printing.GetClaimExport(ctx, tx) if err != nil { return nil, err } out := make([]tableexport.Column, 0, len(layout.Columns)) for _, col := range layout.Columns { t := tableexport.TypeText if claimMoney[col.Key] { t = tableexport.TypeMoney } out = append(out, tableexport.Column{Key: col.Key, Label: col.Label, Type: t, Default: col.Enabled}) } return out, nil}(PermissionClaimRead: use the module’s existing constant for expenses.claim.read — grep -n '"expenses.claim.read"' internal/modules/expenses/permissions.go. layout.Columns is in the org’s saved order, so Defaults reproduces today’s order.)
In export_handlers.go replace the CSV-building block (from var buf bytes.Buffer to c.Data(...)) with:
cat, err := h.claimCatalogue(c) // opens a read tx and calls claimColumns … choices, err := chosenColumns(c, cat) // parses columns/headings; absent -> tableexport.Defaults(cat) cols, err := tableexport.Resolve(cat, choices) format := render.Format(c.DefaultQuery("format", "csv")) rows := make([]map[string]string, len(lines)) for i, l := range lines { rows[i] = l.Values } body, ct, ext, err := tableexport.Write(format, cols, rows) if errors.Is(err, tableexport.ErrTooManyRows) { … 422 code export_too_many_rows } c.Header("Content-Disposition", `attachment; filename="`+name+`.`+ext+`"`) c.Data(http.StatusOK, ct, body)Delete guard, formulaStart, numericColumns (the render writers guard text and write money exactly) and the bytes, encoding/csv, regexp imports if unused. chosenColumns splits columns on commas, headings likewise (len must equal columns’ or be empty → 422 export_headings_mismatch), builds []tableexport.Choice. Map tableexport errors to 422 problems with codes export_unknown_column, export_no_columns, export_duplicate_column, export_heading (same codes as Task 4 — put the mapping function in platform/tableexport/errors.go as func ProblemCode(err error) (string, bool) and use it from both modules to avoid two copies; add it to Task 4’s handlers).
Behaviour change to call out in the commit and ROADMAP: amounts in the claims CSV now print with four decimals (12.5000) because render.Money writes Amount.String(); XLSX holds numbers. If the existing tests assert two decimals, update them to the new strings in the same commit.
openapi.yaml: add the three parameters to exportExpenseClaims; add application/vnd.openxmlformats-officedocument.spreadsheetml.sheet to its 200 content; add 422 export_* codes to its description.
-
Step 4: Run, expect PASS:
go test ./internal/modules/expenses/ -run Export -count=1thenmake generate && go build ./.... -
Step 5: Commit.
Task 6: Web ExportDialog and the claims button
Section titled “Task 6: Web ExportDialog and the claims button”Files:
- Create:
web/src/features/exports/api.ts,ExportDialog.tsx,ExportDialog.test.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/exports.json; register theexportsnamespace whereverclaimsis registered (grep -rn "claimDetail" web/src/lib/i18n/*.ts) - Modify:
web/src/features/claims/exportClaims.ts,web/src/features/claims/ClaimsListPage.tsx(Export button opens the dialog) - Generated:
web/src/lib/api/schema.d.ts(pnpm -C web run generateviamake generate)
Interfaces:
- Consumes: generated types
Schemas['ExportColumn'],Schemas['ExportPreset'],api.GET('/exports/kinds/{kind}/columns'),api.GET('/exports/presets'),api.POST/PUT/DELETE. - Produces:
export function useExportColumns(kind: string): UseQueryResult<ExportColumn[]>export function useExportPresets(kind: string): UseQueryResult<ExportPreset[]>export function useSavePreset(kind: string): UseMutationResult // create or update (revision when updating)export function useDeletePreset(kind: string): UseMutationResultexport interface ExportRequest { columns: string[]; headings: string[]; format: 'csv' | 'xlsx' }export function ExportDialog(props: { kind: string; title: string; onExport: (r: ExportRequest) => Promise<void>; onClose: () => void }): JSX.Element-
Step 1: api.ts following
web/src/features/printing/api.ts: query keys['exports', kind, 'columns']and['exports', kind, 'presets'];unwrap(await api.GET(…, { params: { path: { kind } }, signal })); mutations invalidate the presets key. -
Step 2: Failing test (
ExportDialog.test.tsx, vitest + testing-library, mocking the api module the wayClaimsListPage.test.tsxdoes):- renders one checkbox per catalogue column, the default ones ticked, in catalogue order — presence;
- unticking a column and pressing Export calls
onExportwith only the ticked keys in order,format: 'csv'; - choosing XLSX passes
format: 'xlsx'; - editing a heading passes it at the same index, others
''; - Export is disabled with no columns ticked;
- choosing a saved preset ticks exactly its columns and applies its headings.
-
Step 3: Implement
ExportDialog.tsxwith the repo’sDialog,Button,Field,Input,Select(seeClaimsFilterDialog.tsxfor the props and footer pattern): state = ordered list of{key, label, on, heading}initialised from the catalogue (defaults on); each row has a checkbox, a heading input, and up/down buttons (aria-labeltranslated) to reorder; a formatSelect(CSV/XLSX); a presetSelect(own and shared, shared marked) that overwrites the state; “Save as preset” row (nameInput, share checkbox, Save button callinguseSavePreset); Export button callsonExportthenonClose; show aProblemErrormessage inline using the existing error display used by other dialogs. -
Step 4: exportClaims.ts — change the signature to
downloadClaimsExport(s: ClaimListSearch, r: ExportRequest): addq.set('columns', r.columns.join(',')),q.set('headings', r.headings.join(','))(only when any heading is non-empty),q.set('format', r.format), and name the downloadclaims-….${r.format}. InClaimsListPage.tsxthe Export button setsexportOpenstate; render<ExportDialog kind="claims" title={t('export.title')} onExport={(r) => downloadClaimsExport(search, r)} onClose=… />. Keep the existing busy/error handling at the call site. -
Step 5: Strings
exports.json(en):columns,heading,format,preset,savePreset,presetName,share,moveUp,moveDown,export,noColumns; ms and zh translations; note in ROADMAP that ms/zh are unreviewed. -
Step 6: Run
pnpm -C web test -- ExportDialog ClaimsListPage(updateClaimsListPage.test.tsxwhere it clicked Export and expected an immediate download),pnpm -C web run typecheck,pnpm -C web run lint. -
Step 7: Browser check — start the dev stack per
scripts/dev-stack.sh/ the preview config, open Expense claims, Export: untick two columns, rename one heading, XLSX, open the file in a spreadsheet; save a preset, reload, pick it. Report what you saw; if you could not run it, say so. -
Step 8: Commit; ROADMAP — tick “Spec reviewed … plan written”; add sub-bullets for what landed and “Not verified: …”.
Self-review
Section titled “Self-review”- Spec coverage: registry + catalogue (T2, T4, T5); XLSX/CSV (T1, T2); presets private/shared (T3, T4); dialog (T6); claims first (T5, T6); money exact (T2 test, T1); tenant/RLS tables (T3 + existing drift tests); permission non-widening (T4 case 2). Not in this plan (later plan, after this lands): journal-entries export with
axis.<key>columns and the D365 preset, petty-cash and party kinds, the “list pages without an export” drift test. - Deviation recorded: per-module export routes instead of one
POST /v1/exports/{kind}; Task 4 Step “modify the spec” edits that section. - Type consistency:
Choice,Column,Kind,Resolve,Write,Defaults,ProblemCodeare all defined in T2. - Known gaps to verify while executing: helper names flagged in-line (
money.MustParse,render.KindExport, the second-member test helper,PermissionClaimRead); each has a grep to resolve it.