feat: notification service
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user