Files
zarinpal-analytics-backend/internal/eventcatalog/catalog.go

171 lines
6.9 KiB
Go

package eventcatalog
import (
"errors"
"fmt"
"strings"
"time"
)
type EventType string
type Tone string
type CommercialPosture string
type Recurrence string
const (
TypePublicHoliday EventType = "PUBLIC_HOLIDAY"
TypeReligious EventType = "RELIGIOUS"
TypeNational EventType = "NATIONAL"
TypeCultural EventType = "CULTURAL"
TypeCommercial EventType = "COMMERCIAL"
TypeSeasonal EventType = "SEASONAL"
TypeEducation EventType = "EDUCATION"
TypeEconomic EventType = "ECONOMIC"
TypeEmergency EventType = "EMERGENCY_CLOSURE"
ToneCelebratory Tone = "CELEBRATORY"
ToneJoyful Tone = "JOYFUL"
ToneNeutral Tone = "NEUTRAL"
ToneSolemn Tone = "SOLEMN"
ToneMourning Tone = "MOURNING"
ToneMemorial Tone = "MEMORIAL"
PosturePromote CommercialPosture = "PROMOTE"
PostureCaution CommercialPosture = "CAUTION"
PostureNoPromotion CommercialPosture = "NO_PROMOTION"
RecurrenceBlackFriday Recurrence = "BLACK_FRIDAY"
RecurrenceCyberMonday Recurrence = "CYBER_MONDAY"
)
type Event struct {
ID string `json:"id"`
OccasionKey string `json:"occasionKey,omitempty"`
Variant string `json:"variant,omitempty"`
TitleFA string `json:"titleFa"`
TitleEN string `json:"titleEn,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Themes []string `json:"themes,omitempty"`
CampaignTags []string `json:"campaignTags,omitempty"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
Recurrence Recurrence `json:"recurrence,omitempty"`
Type EventType `json:"eventType"`
Tone Tone `json:"tone"`
CommercialPosture CommercialPosture `json:"commercialPosture"`
OfficialHoliday bool `json:"officialHoliday"`
SourceURI string `json:"sourceUri"`
SourceVersion string `json:"sourceVersion"`
SourceChecksum string `json:"sourceChecksum,omitempty"`
InclusionRationale string `json:"inclusionRationale"`
GeographicScope string `json:"geographicScope"`
MerchantCategories []string `json:"merchantCategories,omitempty"`
BehavioralChannels []string `json:"behavioralChannels,omitempty"`
LeadDays int `json:"leadDays"`
LagDays int `json:"lagDays"`
RelevanceBPS int `json:"relevanceBps"`
ConfidenceBPS int `json:"confidenceBps"`
ImportanceBPS int `json:"importanceBps,omitempty"`
}
func ResolveRecurrence(rule Recurrence, year int, location *time.Location) (time.Time, error) {
if year < 1900 || year > 2200 {
return time.Time{}, errors.New("event catalog: recurrence year out of range")
}
thanksgiving := nthWeekday(year, time.November, time.Thursday, 4, location)
switch rule {
case RecurrenceBlackFriday:
return thanksgiving.AddDate(0, 0, 1), nil
case RecurrenceCyberMonday:
return thanksgiving.AddDate(0, 0, 4), nil
default:
return time.Time{}, fmt.Errorf("event catalog: unsupported recurrence %q", rule)
}
}
func ValidateEvents(events []Event) error {
seen := make(map[string]struct{}, len(events))
canonical := make(map[string]struct{}, len(events))
for _, event := range events {
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.SourceURI) == "" || strings.TrimSpace(event.SourceVersion) == "" || strings.TrimSpace(event.InclusionRationale) == "" || strings.TrimSpace(event.GeographicScope) == "" {
return fmt.Errorf("event catalog: event %q is incomplete", event.ID)
}
if _, exists := seen[event.ID]; exists {
return fmt.Errorf("event catalog: duplicate event %q", event.ID)
}
seen[event.ID] = struct{}{}
if event.OccasionKey == "" {
event.OccasionKey = event.ID
}
if event.Variant == "" {
event.Variant = "PRIMARY"
}
if event.Variant != "PRIMARY" && event.Variant != "CAMPAIGN_WINDOW" && event.Variant != "OPERATIONAL_WINDOW" {
return fmt.Errorf("event catalog: event %q has invalid variant", event.ID)
}
start, err := time.Parse(time.DateOnly, event.StartDate)
if err != nil {
return fmt.Errorf("event catalog: event %q start date: %w", event.ID, err)
}
end, err := time.Parse(time.DateOnly, event.EndDate)
if err != nil || end.Before(start) {
return fmt.Errorf("event catalog: event %q has invalid end date", event.ID)
}
if !validType(event.Type) || !validTone(event.Tone) || !validPosture(event.CommercialPosture) {
return fmt.Errorf("event catalog: event %q has invalid classification", event.ID)
}
if (event.Tone == ToneMourning || event.Tone == ToneMemorial) && event.CommercialPosture == PosturePromote {
return fmt.Errorf("event catalog: event %q cannot promote during mourning or memorial", event.ID)
}
if event.LeadDays < 0 || event.LagDays < 0 || event.RelevanceBPS < 0 || event.RelevanceBPS > 10_000 || event.ConfidenceBPS < 0 || event.ConfidenceBPS > 10_000 || event.ImportanceBPS < 0 || event.ImportanceBPS > 10_000 {
return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID)
}
if event.LeadDays > 90 || event.LagDays > 90 {
return fmt.Errorf("event catalog: event %q lead or lag window exceeds 90 days", event.ID)
}
if event.Recurrence != "" {
resolved, err := ResolveRecurrence(event.Recurrence, start.Year(), time.UTC)
if err != nil {
return fmt.Errorf("event catalog: event %q recurrence: %w", event.ID, err)
}
if !resolved.Equal(start) || !end.Equal(start) {
return fmt.Errorf("event catalog: event %q does not match its resolved recurrence", event.ID)
}
}
canonicalKey := strings.Join([]string{event.OccasionKey, event.Variant, event.StartDate, event.EndDate}, "\x00")
if _, exists := canonical[canonicalKey]; exists {
return fmt.Errorf("event catalog: duplicate canonical occurrence %q", event.ID)
}
canonical[canonicalKey] = struct{}{}
}
return nil
}
func nthWeekday(year int, month time.Month, weekday time.Weekday, occurrence int, location *time.Location) time.Time {
first := time.Date(year, month, 1, 0, 0, 0, 0, location)
offset := (int(weekday) - int(first.Weekday()) + 7) % 7
return first.AddDate(0, 0, offset+7*(occurrence-1))
}
func validType(value EventType) bool {
switch value {
case TypePublicHoliday, TypeReligious, TypeNational, TypeCultural, TypeCommercial, TypeSeasonal, TypeEducation, TypeEconomic, TypeEmergency:
return true
default:
return false
}
}
func validTone(value Tone) bool {
switch value {
case ToneCelebratory, ToneJoyful, ToneNeutral, ToneSolemn, ToneMourning, ToneMemorial:
return true
default:
return false
}
}
func validPosture(value CommercialPosture) bool {
return value == PosturePromote || value == PostureCaution || value == PostureNoPromotion
}