Files
bot-pontos/backend/internal/app/calendar.go
2026-10-23 22:31:02 -03:00

142 lines
4.2 KiB
Go

package app
import (
"context"
"errors"
"strings"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
const stateHolidayFallbackNotified = "holiday_fallback_notified"
// IsBusinessDay aplica a ordem da seção 9.9: fim de semana → tabela holidays
// (com sincronização anual da API) → fallback de segunda a sexta com aviso.
func (s *Service) IsBusinessDay(ctx context.Context, t time.Time, loc *time.Location) (bool, error) {
if rules.IsWeekend(t, loc) {
return false, nil
}
local := t.In(loc)
s.ensureHolidays(ctx, local.Year())
holiday, err := s.store.IsHoliday(ctx, local.Format(domain.DateLayout))
if err != nil {
// Sem banco não há como saber; mantém a automação funcionando.
return true, err
}
return !holiday, nil
}
// ensureHolidays sincroniza o ano com a API na primeira vez e, enquanto não
// der certo, tenta no máximo uma vez por dia (9.9).
func (s *Service) ensureHolidays(ctx context.Context, year int) {
today := s.TodayDate()
s.mu.Lock()
if s.syncedYears[year] || s.syncTried[year] == today {
s.mu.Unlock()
return
}
s.syncTried[year] = today
s.mu.Unlock()
synced, err := s.store.HasSyncedHolidays(ctx, year)
if err == nil && synced {
s.markSynced(year)
return
}
if _, err := s.syncHolidays(ctx, WorkerActor, year); err != nil {
s.holidayFallback(ctx, year, err)
return
}
s.markSynced(year)
}
func (s *Service) markSynced(year int) {
s.mu.Lock()
s.syncedYears[year] = true
s.mu.Unlock()
}
// holidayFallback avisa os admins no máximo uma vez por dia (9.9 e 9.11).
func (s *Service) holidayFallback(ctx context.Context, year int, cause error) {
today := s.TodayDate()
var state struct {
Day string `json:"day"`
}
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &state); found && state.Day == today {
return
}
state.Day = today
if err := s.store.SetState(ctx, stateHolidayFallbackNotified, state); err != nil {
s.log.Warn("falha ao registrar aviso de feriados", "error", err)
}
s.audit(ctx, WorkerActor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(cause)})
s.notifyAdmins(ctx, msgHolidayFallback(year, cause))
}
// SyncHolidays baixa os feriados do ano (worker ou botão do painel).
func (s *Service) SyncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
inserted, err := s.syncHolidays(ctx, actor, year)
if err == nil {
s.markSynced(year)
}
return inserted, err
}
func (s *Service) syncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
if s.holidays == nil {
return 0, domain.ErrNotConfigured
}
holidays, err := s.holidays.FetchYear(ctx, year)
if err != nil {
if actor.Source != domain.SourceWorker {
s.audit(ctx, actor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(err)})
}
return 0, err
}
inserted, err := s.store.InsertHolidays(ctx, holidays)
if err != nil {
return 0, err
}
s.audit(ctx, actor, nil, domain.EventHolidaySynced, true, map[string]any{"year": year, "received": len(holidays), "inserted": inserted})
return inserted, nil
}
func (s *Service) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) {
return s.store.ListHolidays(ctx, year)
}
func (s *Service) AddHoliday(ctx context.Context, actor Actor, day, name string) error {
name = strings.TrimSpace(name)
var errs domain.ValidationErrors
if _, err := time.Parse(domain.DateLayout, day); err != nil {
errs.Add("day", "data inválida (use AAAA-MM-DD)")
}
if name == "" || len(name) > 120 {
errs.Add("name", "obrigatório, até 120 caracteres")
}
if len(errs) > 0 {
return errs
}
err := s.store.AddHoliday(ctx, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceManual})
if errors.Is(err, domain.ErrConflict) {
return domain.ValidationErrors{{Field: "day", Message: "já existe um feriado nessa data"}}
}
if err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventHolidayAdded, true, map[string]any{"day": day, "name": name})
return nil
}
func (s *Service) DeleteHoliday(ctx context.Context, actor Actor, day string) error {
if err := s.store.DeleteManualHoliday(ctx, day); err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventHolidayRemoved, true, map[string]any{"day": day})
return nil
}