Files
galaxy-game/notification/internal/service/malformedintent/model.go
T
2026-04-22 08:49:45 +02:00

136 lines
4.0 KiB
Go

// Package malformedintent defines the operator-visible record used for
// malformed notification intents.
package malformedintent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// FailureCode identifies one stable malformed-intent rejection reason.
type FailureCode string
const (
// FailureCodeInvalidIntent reports malformed top-level intent fields or an
// invalid normalized envelope.
FailureCodeInvalidIntent FailureCode = "invalid_intent"
// FailureCodeInvalidPayload reports malformed or schema-invalid
// `payload_json`.
FailureCodeInvalidPayload FailureCode = "invalid_payload"
// FailureCodeIdempotencyConflict reports a duplicate idempotency scope that
// conflicts with already accepted normalized content.
FailureCodeIdempotencyConflict FailureCode = "idempotency_conflict"
// FailureCodeRecipientNotFound reports that a user-targeted recipient user
// id could not be resolved through User Service.
FailureCodeRecipientNotFound FailureCode = "recipient_not_found"
)
// Entry stores one operator-visible malformed notification-intent record.
type Entry struct {
// StreamEntryID stores the Redis Stream entry identifier of the rejected
// intent.
StreamEntryID string
// NotificationType stores the optional raw notification type extracted from
// the rejected entry.
NotificationType string
// Producer stores the optional raw producer value extracted from the
// rejected entry.
Producer string
// IdempotencyKey stores the optional raw idempotency key extracted from the
// rejected entry.
IdempotencyKey string
// FailureCode stores the stable rejection classification.
FailureCode FailureCode
// FailureMessage stores the detailed validation or decode failure.
FailureMessage string
// RawFields stores the raw top-level stream fields captured for operator
// inspection.
RawFields map[string]any
// RecordedAt stores when the malformed intent was durably recorded.
RecordedAt time.Time
}
// IsKnown reports whether code belongs to the frozen malformed-intent
// rejection surface.
func (code FailureCode) IsKnown() bool {
switch code {
case FailureCodeInvalidIntent, FailureCodeInvalidPayload, FailureCodeIdempotencyConflict, FailureCodeRecipientNotFound:
return true
default:
return false
}
}
// Validate reports whether entry contains a complete malformed-intent record.
func (entry Entry) Validate() error {
if strings.TrimSpace(entry.StreamEntryID) == "" {
return fmt.Errorf("malformed intent stream entry id must not be empty")
}
if !entry.FailureCode.IsKnown() {
return fmt.Errorf("malformed intent failure code %q is unsupported", entry.FailureCode)
}
if strings.TrimSpace(entry.FailureMessage) == "" {
return fmt.Errorf("malformed intent failure message must not be empty")
}
if strings.TrimSpace(entry.FailureMessage) != entry.FailureMessage {
return fmt.Errorf("malformed intent failure message must not contain surrounding whitespace")
}
if entry.RawFields == nil {
return fmt.Errorf("malformed intent raw fields must not be nil")
}
if err := validateJSONObject("malformed intent raw fields", entry.RawFields); err != nil {
return err
}
if err := validateTimestamp("malformed intent recorded at", entry.RecordedAt); err != nil {
return err
}
return nil
}
func validateJSONObject(name string, value map[string]any) error {
payload, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if string(payload) == "null" {
return fmt.Errorf("%s must encode as a JSON object", name)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if decoded == nil {
return fmt.Errorf("%s must encode as a JSON object", name)
}
return nil
}
func validateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
if !value.Equal(value.UTC()) {
return fmt.Errorf("%s must be UTC", name)
}
if !value.Equal(value.Truncate(time.Millisecond)) {
return fmt.Errorf("%s must use millisecond precision", name)
}
return nil
}