package app import ( "context" "errors" "time" "botpontos/internal/domain" "botpontos/internal/rules" ) // TodayView é o resultado de uma consulta ao vivo dos pontos do dia // (/entrada2, /saida2, botão "testar API" e tela do usuário). type TodayView struct { User domain.User Day string Points []time.Time Invalid bool Overtime bool Entrada2At *time.Time Saida2At *time.Time Saida2Error string LunchMinutes int DailyWorkMinutes int DayState domain.UserDay Window domain.QueryWindow CustomWindow bool Resumed bool // a consulta encontrou pontos e retomou um dia encerrado sem pontos } // Today consulta a PontoMais agora. source indica quem pediu, para retomar // automaticamente um dia "sem pontos" quando a consulta manual encontra pontos (9.12). func (s *Service) Today(ctx context.Context, actor Actor, userID int64, source *domain.ResumeSource) (TodayView, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return TodayView{}, err } cred, err := s.requireValidatedCredential(ctx, userID) if err != nil { return TodayView{}, err } schedule, err := s.store.GetSchedule(ctx, userID) if err != nil { return TodayView{}, err } global, err := s.GlobalWindow(ctx) if err != nil { return TodayView{}, err } loc := u.Location() points, err := s.points.History(ctx, cred, loc) if err != nil { return TodayView{}, err } now := s.now() view := TodayView{ User: u, Day: domain.LocalDay(now, loc), LunchMinutes: schedule.LunchMinutes, DailyWorkMinutes: schedule.DailyWorkMinutes, } view.Window, view.CustomWindow = rules.EffectiveWindow(global, schedule) w := rules.CurrentWorkday(points, view.Day, loc) view.Invalid, view.Overtime = w.Invalid, w.Overtime for _, p := range w.Points { view.Points = append(view.Points, p.OccurredAt) } if !w.Invalid && !w.Overtime { if t, err := rules.Entrada2Time(w, schedule.LunchMinutes); err == nil { view.Entrada2At = &t } if w.Count() >= 3 { if t, err := rules.Saida2Time(w, schedule.DailyWorkMinutes); err == nil { view.Saida2At = &t } else { view.Saida2Error = err.Error() } } } if view.DayState, _, err = s.store.GetUserDay(ctx, userID, view.Day); err != nil { return view, err } if source != nil && w.Count() > 0 && view.DayState.IsClosedWithoutPoints() { if d, err := s.resumeDayIfClosed(ctx, actor, u, *source); err == nil { view.DayState, view.Resumed = d, true } } return view, nil } // TestCredentials consulta a PontoMais com a credencial atual (botão do painel). func (s *Service) TestCredentials(ctx context.Context, actor Actor, userID int64) (TodayView, error) { view, err := s.Today(ctx, actor, userID, nil) if err != nil { if errors.Is(err, domain.ErrCredentialsRequired) { // Credencial gravada mas nunca validada também pode ser testada. if cred, cerr := s.store.GetCredential(ctx, userID); cerr == nil { return s.testUnvalidated(ctx, actor, userID, cred) } } s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)}) return view, err } if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil { return view, err } s.audit(ctx, actor, &userID, domain.EventCredentialsTested, true, map[string]any{"points": len(view.Points)}) return view, nil } func (s *Service) testUnvalidated(ctx context.Context, actor Actor, userID int64, cred domain.Credential) (TodayView, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return TodayView{}, err } if _, err := s.points.History(ctx, cred, u.Location()); err != nil { s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)}) return TodayView{}, err } if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil { return TodayView{}, err } return s.Today(ctx, actor, userID, nil) } // ResumeDay retoma um dia encerrado sem pontos (botão do painel, 9.12). func (s *Service) ResumeDay(ctx context.Context, actor Actor, userID int64) (domain.UserDay, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return domain.UserDay{}, err } d, err := s.resumeDayIfClosed(ctx, actor, u, domain.ResumeWeb) if errors.Is(err, errNothingToResume) { return d, domain.InvalidState("o dia não está encerrado por falta de pontos") } return d, err } var errNothingToResume = errors.New("nada a retomar") func (s *Service) resumeDayIfClosed(ctx context.Context, actor Actor, u domain.User, source domain.ResumeSource) (domain.UserDay, error) { loc := u.Location() now := s.now() day := domain.LocalDay(now, loc) d, found, err := s.store.GetUserDay(ctx, u.ID, day) if err != nil { return d, err } if !found || !d.IsClosedWithoutPoints() { return d, errNothingToResume } window, err := s.windowFor(ctx, u.ID) if err != nil { return d, err } d.Status = domain.DayWorking d.ResumedBy = &source d.NextCheckAt = rules.ResumeNextCheck(now, window, day, loc, s.cfg.Jobs.ReconciliationInterval) if err := s.store.SaveUserDay(ctx, d); err != nil { return d, err } s.audit(ctx, actor, &u.ID, domain.EventDayResumed, true, map[string]any{"source": string(source)}) return d, nil } // windowFor devolve a janela efetiva de um usuário. func (s *Service) windowFor(ctx context.Context, userID int64) (domain.QueryWindow, error) { global, err := s.GlobalWindow(ctx) if err != nil { return global, err } schedule, err := s.store.GetSchedule(ctx, userID) if err != nil { return global, err } w, _ := rules.EffectiveWindow(global, schedule) return w, nil } // StatusInfo reúne o que o /status mostra. type StatusInfo struct { User domain.User HasCredential bool Validated bool Failures int Today domain.UserDay Window domain.QueryWindow CustomWindow bool BusinessDay bool } func (s *Service) Status(ctx context.Context, userID int64) (StatusInfo, error) { detail, err := s.UserDetail(ctx, userID) if err != nil { return StatusInfo{}, err } info := StatusInfo{ User: detail.User, Today: detail.Today, Window: detail.Window, CustomWindow: detail.CustomWindow, } if detail.Credential != nil { info.HasCredential = true info.Validated = detail.Credential.IsValidated() info.Failures = detail.Credential.ConsecutiveFailures } info.BusinessDay, _ = s.IsBusinessDay(ctx, s.now(), detail.User.Location()) return info, nil }