package eventcatalog import ( "errors" "fmt" "regexp" "strings" "time" ) var canonicalOccasionKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`) var jalaliBreaks = [...]int{-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178} 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:"eventVariant,omitempty"` JalaliYear int `json:"jalaliYear,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)) canonicalYears := 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 !canonicalOccasionKeyPattern.MatchString(event.OccasionKey) { return fmt.Errorf("event catalog: event %q has invalid canonical occasion key", event.ID) } if hasYearSegment(event.OccasionKey) { return fmt.Errorf("event catalog: event %q has year-specific occasion key", 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{}{} if event.JalaliYear != 0 { canonicalYearKey := fmt.Sprintf("%s\x00%s\x00%d", event.OccasionKey, event.Variant, event.JalaliYear) if _, exists := canonicalYears[canonicalYearKey]; exists { return fmt.Errorf("event catalog: duplicate canonical occurrence in Jalali year %d for event %q", event.JalaliYear, event.ID) } canonicalYears[canonicalYearKey] = struct{}{} } } return nil } // ValidateDeclaredCoverage verifies that each year advertised by an import has // at least one dated event. JalaliYear is explicit review metadata: it avoids // guessing calendar boundaries from Gregorian dates during catalog approval. func ValidateDeclaredCoverage(events []Event, startJalaliYear, endJalaliYear int) error { if startJalaliYear <= 0 || endJalaliYear < startJalaliYear { return errors.New("event catalog: invalid declared Jalali coverage") } covered := make(map[int]struct{}, endJalaliYear-startJalaliYear+1) for _, event := range events { if event.JalaliYear < startJalaliYear || event.JalaliYear > endJalaliYear { return fmt.Errorf("event catalog: event %q lacks valid Jalali year within declared coverage", 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 { return fmt.Errorf("event catalog: event %q end date: %w", event.ID, err) } startJalaliYear := gregorianToJalaliYear(start) endJalaliYear := gregorianToJalaliYear(end) belongsToOccurrenceYear := startJalaliYear == event.JalaliYear || endJalaliYear == event.JalaliYear preparesImmediateNextYear := event.Variant == "CAMPAIGN_WINDOW" && endJalaliYear+1 == event.JalaliYear && gregorianToJalaliYear(end.AddDate(0, 0, 1)) == event.JalaliYear if !belongsToOccurrenceYear && !preparesImmediateNextYear { return fmt.Errorf("event catalog: event %q Jalali year is inconsistent with its Gregorian date", event.ID) } covered[event.JalaliYear] = struct{}{} } for year := startJalaliYear; year <= endJalaliYear; year++ { if _, exists := covered[year]; !exists { return fmt.Errorf("event catalog: declared coverage has no event in Jalali year %d", year) } } return nil } func hasYearSegment(value string) bool { digits := 0 for _, character := range value { if character >= '0' && character <= '9' { digits++ if digits >= 4 { return true } } else { digits = 0 } } return false } func gregorianToJalaliYear(date time.Time) int { jy := date.Year() - 621 firstFarvardin := gregorianDayNumber(date.Year(), 3, jalaliMarchDay(jy)) if gregorianDayNumber(date.Year(), int(date.Month()), date.Day()) < firstFarvardin { jy-- } return jy } func jalaliMarchDay(jy int) int { gy := jy + 621 leapJ := -14 previousBreak := jalaliBreaks[0] jump := 0 for index := 1; index < len(jalaliBreaks); index++ { currentBreak := jalaliBreaks[index] jump = currentBreak - previousBreak if jy < currentBreak { break } leapJ += jump/33*8 + (jump%33)/4 previousBreak = currentBreak } n := jy - previousBreak leapJ += n/33*8 + (n%33+3)/4 if jump%33 == 4 && jump-n == 4 { leapJ++ } leapG := gy/4 - ((gy/100+1)*3)/4 - 150 return 20 + leapJ - leapG } func gregorianDayNumber(gy, gm, gd int) int { dayNumber := ((gy+(gm-8)/6+100100)*1461)/4 + (153*((gm+9)%12)+2)/5 + gd - 34840408 return dayNumber - (((gy+100100+(gm-8)/6)/100)*3)/4 + 752 } 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 }