From fdd56d225978f1769880055e86d5528e57c541b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 19:50:10 +0200 Subject: [PATCH] issue 1858: keep the operator's handling and the action log (1858.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0517 §1 to §3, §9 to §11: migration 0005 and schema.sql add item_handling (per item, gone with its entry) and action_line (what, mail id, item, mode, when; never mail text); the store writes each act and its line in one transaction. --- .../mesh-mail-reader/handling_schema_test.go | 47 ++++++ .../cmd/mesh-mail-reader/kit_test.go | 120 ++++++++++++++++ ...-handling-reminders-and-the-action-log.sql | 18 +++ .../cmd/mesh-mail-reader/pgstore.go | 136 ++++++++++++++++++ .../cmd/mesh-mail-reader/schema.sql | 23 ++- .../cmd/mesh-mail-reader/store.go | 48 +++++++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/handling_schema_test.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0005-handling-reminders-and-the-action-log.sql diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/handling_schema_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/handling_schema_test.go new file mode 100644 index 00000000..cde8918b --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/handling_schema_test.go @@ -0,0 +1,47 @@ +package main + +// Issue 1858 (novox/hq ADR 0517 §3, §9 to §11): the handling, the reminders and the action log in the +// reader's own database — never a column for mail text; handling and reminders go with their entry. + +import ( + "strings" + "testing" +) + +// 1858/AC6: the action log's and the handling's tables hold no column for mail text: only what was done, +// the mail id, the item, the mode and when. +func TestTheActionLogHoldsNoMailText(t *testing.T) { + for which, m := range map[string]*schemaModel{"schema.sql": modelOfConsolidated(t), "the migrations": modelOfMigrations(t)} { + for table, want := range map[string][]string{ + "action_line": {"id", "what", "mail_entry", "item", "mode", "at"}, + "item_handling": {"mail_entry", "item", "handled", "shows_again"}, + } { + tb := m.Tables[table] + if tb == nil { + t.Fatalf("%s has no table %s", which, table) + } + var got []string + for col := range tb.Columns { + got = append(got, col) + } + if len(got) != len(want) { + t.Fatalf("%s: %s has the columns %v, want exactly %v", which, table, got, want) + } + for _, col := range want { + if _, ok := tb.Columns[col]; !ok { + t.Fatalf("%s: %s has the columns %v, want exactly %v", which, table, got, want) + } + } + } + } +} + +// 1858/AC7 (the store's half): an item's handling and reminder are removed with their entry. +func TestHandlingGoesWithItsEntry(t *testing.T) { + for which, m := range map[string]*schemaModel{"schema.sql": modelOfConsolidated(t), "the migrations": modelOfMigrations(t)} { + tb := m.Tables["item_handling"] + if tb == nil || !strings.Contains(tb.Columns["mail_entry"], "references mail_entry(id)on delete cascade") { + t.Fatalf("%s: item_handling.mail_entry does not go with its entry: %v", which, tb) + } + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go index d1918346..b8c9aefb 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go @@ -26,6 +26,126 @@ type memStore struct { nextLesson int64 reread *rereadKept + + handlings []Handling + actions []ActionLine + nextLine int64 + failAct error // Act, Record and CorrectEntry fail once with this, then clear: a failed act writes nothing +} + +func (s *memStore) failing() error { + err := s.failAct + s.failAct = nil + return err +} + +func (s *memStore) addLine(line ActionLine) { + s.nextLine++ + line.ID = s.nextLine + s.actions = append(s.actions, line) +} + +func (s *memStore) Handlings(context.Context) ([]Handling, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Handling(nil), s.handlings...), nil +} + +func (s *memStore) Act(_ context.Context, h Handling, line ActionLine) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.failing(); err != nil { + return err + } + found := false + for _, e := range s.entries { + found = found || e.ID == h.Entry + } + if !found { + return ErrNotFound // PgStore: the foreign key refuses an item of no entry + } + kept := s.handlings[:0] + for _, held := range s.handlings { + if held.Entry != h.Entry || held.Item != h.Item { + kept = append(kept, held) + } + } + s.handlings = kept + if !h.Handled.IsZero() || !h.ShowsAgain.IsZero() { + s.handlings = append(s.handlings, h) + } + s.addLine(line) + return nil +} + +func (s *memStore) Record(_ context.Context, line ActionLine) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.failing(); err != nil { + return err + } + s.addLine(line) + return nil +} + +func (s *memStore) CorrectEntry(_ context.Context, e Entry, l Lesson, line ActionLine) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.failing(); err != nil { + return 0, err + } + at := -1 + for i, held := range s.entries { + if held.ID == e.ID { + at = i + } + } + if at < 0 { + return 0, ErrNotFound + } + s.entries[at] = cloneEntry(e) + s.nextLesson++ + l.ID = s.nextLesson + s.lessons = append(s.lessons, l) + s.addLine(line) + return l.ID, nil +} + +func (s *memStore) Actions(_ context.Context, limit int) ([]ActionLine, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []ActionLine + for i := len(s.actions) - 1; i >= 0 && len(out) < limit; i-- { + out = append(out, s.actions[i]) + } + return out, nil +} + +func (s *memStore) LastAction(_ context.Context, what string) (time.Time, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + var last time.Time + found := false + for _, a := range s.actions { + if a.What == what && (!found || a.At.After(last)) { + last, found = a.At, true + } + } + return last, found, nil +} + +func (s *memStore) RemoveActionsBefore(_ context.Context, before time.Time) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + var kept []ActionLine + for _, a := range s.actions { + if !a.At.Before(before) { + kept = append(kept, a) + } + } + removed := int64(len(s.actions) - len(kept)) + s.actions = kept + return removed, nil } type rereadKept struct { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0005-handling-reminders-and-the-action-log.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0005-handling-reminders-and-the-action-log.sql new file mode 100644 index 00000000..b9317d67 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0005-handling-reminders-and-the-action-log.sql @@ -0,0 +1,18 @@ +-- Issue 1858 (novox/hq ADR 0517 §1 to §5, §9 to §11): the operator's handling of each item of a mail entry +-- (a task, a promise, or the mail itself), the day a reminded item shows again, and the action log: one line +-- per act on the mail page, holding what was done, the mail id, the item, the mode and when; never mail text. +-- Handling goes with its entry; a line is kept 365 days. +create table item_handling ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + item text not null check (item in ('mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')), + handled timestamptz, + shows_again date, + primary key (mail_entry, item)); +create table action_line ( + id bigserial primary key, + what text not null check (what in ('handled', 'undone', 'reminded', 'opened', 'corrected', 'mode', 'page opened')), + mail_entry bigint, + item text not null default '' check (item in ('', 'mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')), + mode text not null default '' check (mode in ('', 'work', 'personal', 'everything')), + at timestamptz not null default now()); +create index action_line_at on action_line (at); diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go index 27af7f75..89baf576 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go @@ -659,3 +659,139 @@ func (s *PgStore) UndoLesson(ctx context.Context, id int64) error { } return nil } + +// ---- handling and the action log (novox/hq ADR 0517, issue 1858) ------------------------------------------------ + +func (s *PgStore) Handlings(ctx context.Context) ([]Handling, error) { + rows, err := s.pool.Query(ctx, `select mail_entry, item, handled, shows_again from item_handling`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Handling + for rows.Next() { + var h Handling + var handled, showsAgain *time.Time + if err := rows.Scan(&h.Entry, &h.Item, &handled, &showsAgain); err != nil { + return nil, err + } + if handled != nil { + h.Handled = handled.UTC() + } + if showsAgain != nil { + h.ShowsAgain = *showsAgain + } + out = append(out, h) + } + return out, rows.Err() +} + +// insertLine writes one action line in the caller's transaction: the one statement that writes the action log +// (ADR 0517 §5; TestOnlyTheActsWriteTheActionLog). +func insertLine(ctx context.Context, tx pgx.Tx, line ActionLine) error { + var entry *int64 + if line.Entry != 0 { + entry = &line.Entry + } + _, err := tx.Exec(ctx, `insert into action_line (what, mail_entry, item, mode, at) values ($1, $2, $3, $4, $5)`, + line.What, entry, line.Item, line.Mode, line.At) + return err +} + +// inTx runs f in one transaction, committed only when f succeeds. +func (s *PgStore) inTx(ctx context.Context, f func(tx pgx.Tx) error) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + if err := f(tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *PgStore) Act(ctx context.Context, h Handling, line ActionLine) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + if h.Handled.IsZero() && h.ShowsAgain.IsZero() { + if _, err := tx.Exec(ctx, `delete from item_handling where mail_entry = $1 and item = $2`, h.Entry, h.Item); err != nil { + return err + } + } else if _, err := tx.Exec(ctx, `insert into item_handling (mail_entry, item, handled, shows_again) values ($1, $2, $3, $4) + on conflict (mail_entry, item) do update set handled = excluded.handled, shows_again = excluded.shows_again`, + h.Entry, h.Item, nullDate(h.Handled), nullDate(h.ShowsAgain)); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return ErrNotFound + } + return err + } + return insertLine(ctx, tx, line) + }) +} + +func (s *PgStore) Record(ctx context.Context, line ActionLine) error { + return s.inTx(ctx, func(tx pgx.Tx) error { return insertLine(ctx, tx, line) }) +} + +func (s *PgStore) CorrectEntry(ctx context.Context, e Entry, l Lesson, line ActionLine) (int64, error) { + var id int64 + err := s.inTx(ctx, func(tx pgx.Tx) error { + if err := updateEntryIn(ctx, tx, e); err != nil { + return err + } + var group *int64 + if l.Group != 0 { + group = &l.Group + } + if err := tx.QueryRow(ctx, `insert into reading_lesson + (account, folder, sender_name, sender_address, subject, mail_group, change, made, undone) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`, + l.Account, l.Folder, l.SenderName, l.SenderAddress, l.Subject, group, l.Change, l.Made, l.Undone).Scan(&id); err != nil { + return err + } + return insertLine(ctx, tx, line) + }) + return id, err +} + +func (s *PgStore) Actions(ctx context.Context, limit int) ([]ActionLine, error) { + rows, err := s.pool.Query(ctx, `select id, what, mail_entry, item, mode, at from action_line order by at desc, id desc limit $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ActionLine + for rows.Next() { + var a ActionLine + var entry *int64 + if err := rows.Scan(&a.ID, &a.What, &entry, &a.Item, &a.Mode, &a.At); err != nil { + return nil, err + } + if entry != nil { + a.Entry = *entry + } + a.At = a.At.UTC() + out = append(out, a) + } + return out, rows.Err() +} + +func (s *PgStore) LastAction(ctx context.Context, what string) (time.Time, bool, error) { + var at *time.Time + if err := s.pool.QueryRow(ctx, `select max(at) from action_line where what = $1`, what).Scan(&at); err != nil { + return time.Time{}, false, err + } + if at == nil { + return time.Time{}, false, nil + } + return at.UTC(), true, nil +} + +func (s *PgStore) RemoveActionsBefore(ctx context.Context, before time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `delete from action_line where at < $1`, before) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql index b91f57a2..a4ef9f7d 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql @@ -1,4 +1,4 @@ --- The consolidated schema, through migration 0004: what a fresh database is given at once (the mesh's rule: a +-- The consolidated schema, through migration 0005: what a fresh database is given at once (the mesh's rule: a -- numbered migration for the running store, and this for a fresh install). create table mail_group ( id bigserial primary key, @@ -106,3 +106,24 @@ create table rereading ( ended timestamptz not null, mails integer not null, constraint rereading_once check (id = 1)); + +-- The operator's handling (novox/hq ADR 0517 §1, §2, §10, issue 1858): per item of an entry — a task, a +-- promise, or the mail itself — when it was handled, and the day a reminded item shows again. It goes with +-- its entry. +create table item_handling ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + item text not null check (item in ('mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')), + handled timestamptz, + shows_again date, + primary key (mail_entry, item)); + +-- The action log (ADR 0517 §3, §5, §9): one line per act on the mail page — what was done, the mail id, the +-- item, the mode and when; never mail text. Kept 365 days. +create table action_line ( + id bigserial primary key, + what text not null check (what in ('handled', 'undone', 'reminded', 'opened', 'corrected', 'mode', 'page opened')), + mail_entry bigint, + item text not null default '' check (item in ('', 'mail', 'task 1', 'task 2', 'task 3', 'promise 1', 'promise 2', 'promise 3')), + mode text not null default '' check (mode in ('', 'work', 'personal', 'everything')), + at timestamptz not null default now()); +create index action_line_at on action_line (at); diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go index 73bd19e2..1c9544fc 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go @@ -240,8 +240,56 @@ type Store interface { // that it was, when it started and ended and how many mails it read, so a restart never starts it again. RereadingDone(ctx context.Context) (bool, error) KeepRereading(ctx context.Context, started, ended time.Time, mails int) error + + // Handlings answers every item's handling and reminder (ADR 0517 §1, §2, issue 1858). + Handlings(ctx context.Context) ([]Handling, error) + // Act replaces one item's handling with h — a zero Handled and a zero ShowsAgain remove it — and writes + // line, in one transaction: an act that fails writes no line (ADR 0517 §5). + Act(ctx context.Context, h Handling, line ActionLine) error + // Record writes one line that changes no entry: opened, a mode, the page opened (ADR 0517 §4, §5). + Record(ctx context.Context, line ActionLine) error + // CorrectEntry is correct's act (ADR 0459 §8, ADR 0517 §5): the entry updated, the reading lesson kept and + // the corrected line written, in one transaction; it answers the lesson's id. ErrNotFound when unknown. + CorrectEntry(ctx context.Context, e Entry, l Lesson, line ActionLine) (int64, error) + // Actions answers the action log, newest first, at most limit lines. + Actions(ctx context.Context, limit int) ([]ActionLine, error) + // LastAction answers when the newest line of what was written, and whether there is one. + LastAction(ctx context.Context, what string) (time.Time, bool, error) + // RemoveActionsBefore removes every line older than before, answering how many (ADR 0517 §9). + RemoveActionsBefore(ctx context.Context, before time.Time) (int64, error) } +// Handling is one item's handling (ADR 0517 §1, §2): the item ("mail", "task n" or "promise n"), when it was +// handled (zero while it is not), and the day it shows again after "remind me tomorrow" (zero for none). +type Handling struct { + Entry int64 + Item string + Handled time.Time + ShowsAgain time.Time // a day: midnight UTC of that date +} + +// ActionLine is one line of the action log (ADR 0517 §3): what was done, the mail entry it concerns (0 for +// none), the item ("" for none), the mode and when — never text from a mail. +type ActionLine struct { + ID int64 + What string + Entry int64 + Item string + Mode string + At time.Time +} + +// What an action line says was done (ADR 0517 §3); the table's own check names the same. +const ( + ActHandled = "handled" + ActUndone = "undone" + ActReminded = "reminded" + ActOpened = "opened" + ActCorrected = "corrected" + ActMode = "mode" + ActPageOpened = "page opened" +) + // ErrNotFound is answered by a Store method asked for something it does not hold. var ErrNotFound = notFoundError{}