Files
multiarch-docker-images/archivist/internal/pbapp/hooks.go
T
thomas 7b25b7b144 feat(ui): US-22 becomes a side-effect-free draft correction; add US-24 S3 deletion
US-22: the PocketBase UI edits one field at a time with no preview of
the final name, so a field edit now persists the values with no template
evaluation, no re-filing and no status change; the corrected event carries
the field diff alone. The full correction (all fields, dry-run, re-filing,
validation) moves to the custom dashboard (US-3x).
US-24: deleting a documents record deletes the S3 object at file_key first;
the cascade delete removes the history. Spec, hooks, E2E updated together.
2026-11-04 22:08:31 +01:00

224 lines
7.0 KiB
Go

package pbapp
import (
"encoding/json"
"fmt"
"time"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/napnap75/archivist/internal/config"
"github.com/napnap75/archivist/internal/storage"
)
// BindHooks registers the request hooks (US-04, US-22, US-23, US-24).
func BindHooks(app core.App, cfg *config.Config, s3 *storage.Client) {
// US-04: document_events is append-only for everyone, superusers included.
app.OnRecordUpdateRequest(eventsName).BindFunc(func(e *core.RecordRequestEvent) error {
return errAppendOnly
})
app.OnRecordDeleteRequest(eventsName).BindFunc(func(e *core.RecordRequestEvent) error {
return errAppendOnly
})
// US-24: deleting a documents record deletes the underlying S3 object
// first; on failure the deletion is refused and both the record and
// the object are left in place. Like US-04, the hook binds to
// superusers as well: API rules alone cannot enforce it.
app.OnRecordDeleteRequest("documents").BindFunc(func(e *core.RecordRequestEvent) error {
key := e.Record.GetString("file_key")
if key == "" {
return e.Next()
}
if err := s3.Remove(e.Request.Context(), key); err != nil {
return fmt.Errorf("refused: S3 deletion of %s failed: %v", key, err)
}
return e.Next()
})
// US-22 / US-23: draft corrections and validations through the standard UI.
app.OnRecordUpdateRequest("documents").BindFunc(func(e *core.RecordRequestEvent) error {
return handleDocumentUpdate(app, cfg, e)
})
}
// handleDocumentUpdate implements US-22 and US-23. A field edit from the
// standard UI is a draft correction: the values persist as they are, no
// template evaluation, no re-filing, no status change — the UI edits one
// field at a time and shows no preview of the resulting file name. The full
// correction (all fields, dry-run, re-filing, validation) is the custom
// dashboard's job (US-3x). The only direct status change is processed ->
// validated (US-23).
func handleDocumentUpdate(app core.App, cfg *config.Config, e *core.RecordRequestEvent) error {
newRec := e.Record
// The original state, before the submitted data is persisted.
orig, err := app.FindRecordById("documents", newRec.Id)
if err != nil {
return err
}
oldStatus := orig.GetString("status")
newStatus := newRec.GetString("status")
fieldsChanged := false
for _, f := range cfg.FieldList {
if fmt.Sprintf("%v", orig.Get(f.Name)) != fmt.Sprintf("%v", newRec.Get(f.Name)) {
fieldsChanged = true
break
}
}
// US-23: the only direct status change is processed -> validated.
if newStatus != oldStatus && (newStatus != "validated" || oldStatus != "processed") {
return fmt.Errorf("refused: the only direct status change is processed -> validated; full corrections belong to the review dashboard")
}
// US-22: append the corrected event carrying the field diff alone;
// file_key and status are absent since they did not change.
if fieldsChanged {
before, after := DiffSnapshots(SnapshotFromRecord(cfg, orig, false), SnapshotFromRecord(cfg, newRec, false))
if len(before) > 0 || len(after) > 0 {
if err := AppendEvent(app, newRec.Id, "corrected", before, after, nil); err != nil {
app.Logger().Error("corrected event append failed", "document", newRec.Id, "error", err)
}
}
}
// US-23: the validated event, snapshots carrying the status change alone.
if newStatus != oldStatus {
before := Snapshot{"status": oldStatus}
after := Snapshot{"status": "validated"}
if err := AppendEvent(app, newRec.Id, "validated", before, after, nil); err != nil {
return err
}
}
return e.Next()
}
// FieldsFromRecord reads the declared field values with their native types.
func FieldsFromRecord(cfg *config.Config, record *core.Record) map[string]any {
out := map[string]any{}
for _, f := range cfg.FieldList {
switch f.Type {
case "date":
if !record.GetDateTime(f.Name).IsZero() {
out[f.Name] = record.GetDateTime(f.Name).Time()
}
case "number":
if record.GetFloat(f.Name) != 0 {
out[f.Name] = record.GetFloat(f.Name)
}
case "boolean":
if record.GetBool(f.Name) {
out[f.Name] = true
}
default:
if s := record.GetString(f.Name); s != "" {
out[f.Name] = s
}
}
}
return out
}
// DiffSnapshots reduces two snapshots to the keys whose value differs
// between them: a key with the same value on both sides is omitted, a
// key present on one side only is kept on that side (added or deleted
// field). It is the section-4 payload rule: before/after carry only what
// the event changes.
func DiffSnapshots(before, after Snapshot) (Snapshot, Snapshot) {
keys := map[string]bool{}
for k := range before {
keys[k] = true
}
for k := range after {
keys[k] = true
}
db, da := Snapshot{}, Snapshot{}
for k := range keys {
bv, bok := before[k]
av, aok := after[k]
if bok && aok && fmt.Sprintf("%v", bv) == fmt.Sprintf("%v", av) {
continue
}
if bok {
db[k] = bv
}
if aok {
da[k] = av
}
}
return db, da
}
// Snapshot is one before/after state of a document (arch.md §4).
type Snapshot map[string]any
// SnapshotFromRecord builds the full snapshot of a record: file_key, status
// when set, and the recognized declared fields. AppendEvent reduces pairs of
// such snapshots to what actually changes.
func SnapshotFromRecord(cfg *config.Config, record *core.Record, withStatus bool) Snapshot {
s := Snapshot{"file_key": record.GetString("file_key")}
if withStatus {
if st := record.GetString("status"); st != "" {
s["status"] = st
}
}
for name, v := range FieldsFromRecord(cfg, record) {
s[name] = v
}
return s
}
// Payload is the self-contained event payload (arch.md §4).
type Payload struct {
EventType string `json:"event_type"`
Timestamp string `json:"timestamp"`
Errors []string `json:"errors,omitempty"`
Before Snapshot `json:"before"`
After Snapshot `json:"after"`
}
// AppendEvent appends one event to document_events.
func AppendEvent(app core.App, documentId, eventType string, before, after Snapshot, errs []string) error {
coll, err := app.FindCollectionByNameOrId(eventsName)
if err != nil {
return err
}
now := time.Now().UTC()
// The stored payload carries the event's diff only: unchanged keys
// are dropped from both sides (arch.md section 4).
before, after = DiffSnapshots(before, after)
p := Payload{
EventType: eventType,
Timestamp: now.Format(time.RFC3339),
Errors: errs,
Before: before,
After: after,
}
data, err := json.Marshal(p)
if err != nil {
return err
}
rec := core.NewRecord(coll)
rec.Set("document", documentId)
rec.Set("event_type", eventType)
rec.Set("payload", string(data))
rec.Set("timestamp", now)
return app.Save(rec)
}
// AppendImportedEvent builds the imported event of US-16.
func AppendImportedEvent(app core.App, documentId, fromKey, toKey string, valid bool, fields map[string]any, errs []string) error {
before := Snapshot{"file_key": fromKey}
after := Snapshot{"file_key": toKey}
if valid {
after["status"] = "processed"
} else {
after["status"] = "pending"
}
for k, v := range fields {
after[k] = v
}
return AppendEvent(app, documentId, "imported", before, after, errs)
}