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). 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-22 / US-23: corrections and validations through the standard UI. app.OnRecordUpdateRequest("documents").BindFunc(func(e *core.RecordRequestEvent) error { return handleDocumentUpdate(app, cfg, s3, e) }) } func handleDocumentUpdate(app core.App, cfg *config.Config, s3 *storage.Client, 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 } } if !fieldsChanged { // US-23: direct validation, processed -> validated, no field change. if newStatus == "validated" && oldStatus == "processed" { 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() } if newStatus != oldStatus { return fmt.Errorf("refused: the only direct status change is processed -> validated; other changes go through a field correction") } return e.Next() } // US-22: a field correction. Whatever the previous status, the document // becomes validated after a successful re-filing. fields := FieldsFromRecord(cfg, newRec) tplName := "default" if v, ok := fields["template"].(string); ok && v != "" { tplName = v } tpl, ok := cfg.Templates[tplName] if !ok { return fmt.Errorf("refused: unknown template %q", tplName) } rendered, err := tpl.Render(fields, nil) if err != nil { return fmt.Errorf("refused: %v", err) } oldKey := orig.GetString("file_key") target := "files/" + rendered finalKey := oldKey if target != oldKey { finalKey, err = s3.ResolveConflict(e.Request.Context(), target) if err != nil { return err } if err := s3.Copy(e.Request.Context(), oldKey, finalKey); err != nil { return fmt.Errorf("refused: re-filing failed: %v", err) } if err := s3.Remove(e.Request.Context(), oldKey); err != nil { // Roll back the partial copy so the S3 state matches the record. _ = s3.Remove(e.Request.Context(), finalKey) return fmt.Errorf("refused: re-filing failed: %v", err) } } newRec.Set("file_key", finalKey) newRec.Set("status", "validated") before := SnapshotFromRecord(cfg, orig, true) after := SnapshotFromRecord(cfg, newRec, true) if err := AppendEvent(app, newRec.Id, "corrected", before, after, nil); err != nil { app.Logger().Error("corrected event append failed", "document", newRec.Id, "error", 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 } // Snapshot is one before/after state of a document (arch.md §4). type Snapshot map[string]any // SnapshotFromRecord builds a snapshot: file_key always, status when it is // set, and the recognized declared fields. 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() 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) }