feat(models): add alert and box models

Adds comprehensive data structures for Alert and Box functionality across models.
This commit is contained in:
2026-04-30 19:45:22 +03:00
parent 2714907ff4
commit e103829870
16 changed files with 2359 additions and 10 deletions

247
lib/metastore/alerts.go Normal file
View File

@@ -0,0 +1,247 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
const (
AlertSeverityLow = "low"
AlertSeverityMedium = "medium"
AlertSeverityHigh = "high"
AlertStatusOpen = "open"
AlertStatusAcknowledged = "acknowledged"
AlertStatusClosed = "closed"
)
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
alert, err := normalizeAlertInput(input)
if err != nil {
return Alert{}, err
}
id, err := helpers.RandomHexID(16)
if err != nil {
return Alert{}, err
}
now := time.Now().UTC()
alert.ID = id
alert.Status = AlertStatusOpen
alert.CreatedAt = now
alert.UpdatedAt = now
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, alertKey(alert.ID), alert)
})
return alert, err
}
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
alerts := []Alert{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("alert/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var alert Alert
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &alert)
}); err != nil {
return err
}
if alertMatchesFilters(alert, filters) {
alerts = append(alerts, alert)
}
}
return nil
})
if err != nil {
return nil, err
}
sortAlerts(alerts, filters.Sort)
return alerts, nil
}
func (store *Store) GetAlert(id string) (Alert, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return Alert{}, false, nil
}
var alert Alert
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, alertKey(id), &alert)
})
if errors.Is(err, ErrNotFound) {
return Alert{}, false, nil
}
return alert, err == nil, err
}
func (store *Store) AcknowledgeAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusAcknowledged)
}
func (store *Store) CloseAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusClosed)
}
func (store *Store) updateAlertStatus(id string, status string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
}
status, err := normalizeAlertStatus(status)
if err != nil {
return err
}
now := time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var alert Alert
if err := getJSON(txn, alertKey(id), &alert); err != nil {
return err
}
alert.Status = status
alert.UpdatedAt = now
switch status {
case AlertStatusAcknowledged:
alert.AcknowledgedAt = &now
case AlertStatusClosed:
alert.ClosedAt = &now
}
return putJSON(txn, alertKey(id), alert)
})
}
func normalizeAlertInput(input AlertInput) (Alert, error) {
title := strings.TrimSpace(input.Title)
description := strings.TrimSpace(input.Description)
code := strings.TrimSpace(input.Code)
trace := strings.TrimSpace(input.Trace)
severity, err := normalizeAlertSeverity(input.Severity)
if err != nil {
return Alert{}, err
}
if title == "" {
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
}
if code == "" {
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
}
if trace == "" {
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
}
metadata := input.Metadata
if len(metadata) == 0 {
metadata = json.RawMessage(`{}`)
}
var object map[string]any
if err := json.Unmarshal(metadata, &object); err != nil {
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
}
normalizedMetadata, err := json.Marshal(object)
if err != nil {
return Alert{}, err
}
return Alert{
Title: title,
Description: description,
Severity: severity,
Code: code,
Trace: trace,
Metadata: normalizedMetadata,
CreatedBy: strings.TrimSpace(input.CreatedBy),
}, nil
}
func normalizeAlertSeverity(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
}
}
func normalizeAlertStatus(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
}
}
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
if !strings.Contains(haystack, query) {
return false
}
}
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
return false
}
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
return false
}
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
return false
}
return true
}
func sortAlerts(alerts []Alert, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
case "severity":
sort.Slice(alerts, func(i int, j int) bool {
left := alertSeverityRank(alerts[i].Severity)
right := alertSeverityRank(alerts[j].Severity)
if left == right {
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
}
return left > right
})
default:
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
}
}
func alertSeverityRank(severity string) int {
switch severity {
case AlertSeverityHigh:
return 3
case AlertSeverityMedium:
return 2
default:
return 1
}
}
func alertGroup(trace string) string {
trace = strings.TrimSpace(trace)
if trace == "" {
return "system"
}
before, _, found := strings.Cut(trace, ".")
if !found || before == "" {
return "system"
}
return strings.ToLower(before)
}
func alertKey(id string) []byte {
return []byte("alert/" + strings.TrimSpace(id))
}

View File

@@ -0,0 +1,89 @@
package metastore
import (
"encoding/json"
"testing"
)
func TestAlertCreateListFilterLifecycle(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
alert, err := store.CreateAlert(AlertInput{
Title: "Thumbnail failed",
Description: "Could not generate preview.",
Severity: AlertSeverityMedium,
Code: "601",
Trace: "thumbnail.generate.failed",
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
CreatedBy: "system",
})
if err != nil {
t.Fatalf("CreateAlert returned error: %v", err)
}
if alert.ID == "" || alert.Status != AlertStatusOpen {
t.Fatalf("unexpected alert: %#v", alert)
}
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
if err != nil {
t.Fatalf("ListAlerts returned error: %v", err)
}
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
t.Fatalf("unexpected filtered alerts: %#v", alerts)
}
if !json.Valid(alerts[0].Metadata) {
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
}
var metadata map[string]string
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
t.Fatalf("Unmarshal metadata returned error: %v", err)
}
if metadata["file"] != "photo.jpg" {
t.Fatalf("metadata did not survive round trip: %#v", metadata)
}
if err := store.AcknowledgeAlert(alert.ID); err != nil {
t.Fatalf("AcknowledgeAlert returned error: %v", err)
}
acknowledged, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
}
if err := store.CloseAlert(alert.ID); err != nil {
t.Fatalf("CloseAlert returned error: %v", err)
}
closed, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
t.Fatalf("expected closed alert, got %#v", closed)
}
}
func TestAlertRejectsInvalidMetadata(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
if _, err := store.CreateAlert(AlertInput{
Title: "Bad alert",
Severity: AlertSeverityLow,
Code: "999",
Trace: "test.bad",
Metadata: json.RawMessage(`[]`),
}); err == nil {
t.Fatal("expected non-object metadata to be rejected")
}
}

188
lib/metastore/boxes.go Normal file
View File

@@ -0,0 +1,188 @@
package metastore
import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
)
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
record.ID = strings.TrimSpace(record.ID)
if record.ID == "" {
return errors.New("box id cannot be empty")
}
record.OwnerID = strings.TrimSpace(record.OwnerID)
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
record.FileNames = uniqueStrings(record.FileNames)
record.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, boxRecordKey(record.ID), record)
})
}
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
var record BoxRecord
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, boxRecordKey(id), &record)
})
if errors.Is(err, ErrNotFound) {
return BoxRecord{}, false, nil
}
return record, err == nil, err
}
func (store *Store) DeleteBoxRecord(id string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(boxRecordKey(id))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
if page.Page < 1 {
page.Page = 1
}
switch page.PageSize {
case 25, 50, 100:
default:
page.PageSize = 25
}
rows := []BoxRecord{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("box_record/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var record BoxRecord
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &record)
}); err != nil {
return err
}
if boxRecordMatches(record, filters) {
rows = append(rows, record)
}
}
return nil
})
if err != nil {
return BoxRecordPage{}, err
}
sortBoxRecords(rows, filters.Sort)
total := len(rows)
start := (page.Page - 1) * page.PageSize
if start > total {
start = total
}
end := start + page.PageSize
if end > total {
end = total
}
totalPages := 1
if total > 0 {
totalPages = (total + page.PageSize - 1) / page.PageSize
}
return BoxRecordPage{
Rows: rows[start:end],
Page: page.Page,
PageSize: page.PageSize,
Total: total,
HasPrev: page.Page > 1,
HasNext: end < total,
PrevPage: maxInt(page.Page-1, 1),
NextPage: page.Page + 1,
TotalPages: totalPages,
}, nil
}
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
if !strings.Contains(haystack, query) {
return false
}
}
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
return false
}
status := strings.ToLower(strings.TrimSpace(filters.Status))
if status != "" && status != "all" && boxRecordStatus(record) != status {
return false
}
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
case "", "all":
return true
case "password":
return record.PasswordProtected
case "one-time":
return record.OneTimeDownload
case "zip-disabled":
return record.DisableZip
case "expired":
return boxRecordExpired(record)
case "refreshable":
return !record.OneTimeDownload && !boxRecordExpired(record)
default:
return false
}
}
func sortBoxRecords(rows []BoxRecord, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
case "largest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
case "expires":
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
case "expired":
sort.Slice(rows, func(i int, j int) bool {
left := boxRecordExpired(rows[i])
right := boxRecordExpired(rows[j])
if left == right {
return rows[i].CreatedAt.After(rows[j].CreatedAt)
}
return left
})
default:
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
}
}
func boxRecordStatus(record BoxRecord) string {
if boxRecordExpired(record) {
return "expired"
}
if record.ExpiresAt.IsZero() {
return "pending"
}
return "active"
}
func boxRecordExpired(record BoxRecord) bool {
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
}
func boxRecordKey(id string) []byte {
return []byte("box_record/" + strings.TrimSpace(id))
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -1,6 +1,9 @@
package metastore
import "time"
import (
"encoding/json"
"time"
)
const AdminTagName = "admin"
@@ -74,3 +77,78 @@ type BootstrapResult struct {
AdminUser *User
AdminLoginEnabled bool
}
type Alert struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Code string `json:"code"`
Trace string `json:"trace"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
CreatedBy string `json:"created_by"`
}
type AlertInput struct {
Title string
Description string
Severity string
Code string
Trace string
Metadata json.RawMessage
CreatedBy string
}
type AlertFilters struct {
Query string
Severity string
Status string
Group string
Sort string
}
type BoxRecord struct {
ID string `json:"id"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
FileNames []string `json:"file_names,omitempty"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
DisableZip bool `json:"disable_zip"`
RefreshCount int `json:"refresh_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type BoxFilters struct {
Query string
Owner string
Status string
Flag string
Sort string
}
type BoxPageRequest struct {
Page int
PageSize int
}
type BoxRecordPage struct {
Rows []BoxRecord
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
TotalPages int
}