// service.go defines application read ports and use-case orchestration. // Layer: internal/app core business-facing API. package app import ( "context" "strings" "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) // Repository defines outbound data access used by weatherapi use cases. type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } // OutlookFilter selects outlook entries from the latest convective outlook run. type OutlookFilter struct { Day *int OutlookType string ContainsLocation *bool ActiveAt *time.Time } // Service provides weather read use-cases. type Service struct { repo Repository } func NewService(repo Repository) *Service { return &Service{repo: repo} } func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) { return s.repo.LatestObservation(ctx) } func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) { return s.repo.LatestHourlyForecast(ctx) } func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error) { return s.repo.LatestNarrativeForecast(ctx) } func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) { return s.repo.LatestForecastDiscussion(ctx) } func (s *Service) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) { return s.repo.LatestWeatherStoryRun(ctx) } func (s *Service) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) { return s.repo.LatestWeatherStory(ctx) } func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) { return s.repo.LatestAlertRun(ctx) } func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) { run, err := s.repo.LatestAlertRun(ctx) if err != nil { return nil, err } if run == nil { return nil, nil } out := cloneAlertRun(run) alerts := out.Alerts[:0] for _, alert := range out.Alerts { if isActiveAlert(alert, activeAt) { alerts = append(alerts, alert) } } out.Alerts = alerts return out, nil } func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) { run, err := s.repo.LatestConvectiveOutlookRun(ctx) if err != nil { return nil, err } if run == nil { return nil, nil } out := cloneOutlookRun(run) outlooks := out.Outlooks[:0] for _, outlook := range out.Outlooks { if matchesOutlookFilter(outlook, filter) { outlooks = append(outlooks, outlook) } } out.Outlooks = outlooks out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks) return out, nil } func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) { return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault) } func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bool { if filter.Day != nil && outlook.Day != *filter.Day { return false } if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) { return false } if filter.ContainsLocation != nil && outlook.ContainsLocation != *filter.ContainsLocation { return false } if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) { return false } return true } func normalizeOutlookType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun { out := *run out.Latitude = copyFloat64(run.Latitude) out.Longitude = copyFloat64(run.Longitude) if run.Alerts != nil { out.Alerts = make([]model.WeatherAlert, len(run.Alerts)) for i := range run.Alerts { out.Alerts[i] = cloneAlert(run.Alerts[i]) } } return &out } func cloneAlert(alert model.WeatherAlert) model.WeatherAlert { out := alert out.Sent = copyTime(alert.Sent) out.Effective = copyTime(alert.Effective) out.Onset = copyTime(alert.Onset) out.Expires = copyTime(alert.Expires) if alert.References != nil { out.References = make([]model.AlertReference, len(alert.References)) for i := range alert.References { out.References[i] = cloneAlertReference(alert.References[i]) } } return out } func cloneAlertReference(ref model.AlertReference) model.AlertReference { out := ref out.Sent = copyTime(ref.Sent) return out } func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool { if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") { return false } if alert.Effective != nil && activeAt.Before(*alert.Effective) { return false } if alert.Expires != nil && !activeAt.Before(*alert.Expires) { return false } return true } func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun { out := *run out.Latitude = copyFloat64(run.Latitude) out.Longitude = copyFloat64(run.Longitude) out.IssuedAt = copyTime(run.IssuedAt) if run.Outlooks != nil { out.Outlooks = make([]model.WeatherOutlook, len(run.Outlooks)) for i := range run.Outlooks { out.Outlooks[i] = cloneOutlook(run.Outlooks[i]) } } if run.Discussions != nil { out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions)) for i := range run.Discussions { out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i]) } } return &out } func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook { out := outlook out.SeverityRank = copyInt(outlook.SeverityRank) if outlook.Geometry != nil { out.Geometry = append([]byte(nil), outlook.Geometry...) } return out } func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion { out := discussion out.UpdatedAt = copyTime(discussion.UpdatedAt) return out } func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion { if len(outlooks) == 0 { return []model.WeatherOutlookDiscussion{} } retainedDays := make(map[int]struct{}, len(outlooks)) for _, outlook := range outlooks { retainedDays[outlook.Day] = struct{}{} } out := discussions[:0] for _, discussion := range discussions { if _, ok := retainedDays[discussion.Day]; ok { out = append(out, discussion) } } return out } func copyFloat64(value *float64) *float64 { if value == nil { return nil } out := *value return &out } func copyInt(value *int) *int { if value == nil { return nil } out := *value return &out } func copyTime(value *time.Time) *time.Time { if value == nil { return nil } out := *value return &out }