package app import ( "context" "errors" "fmt" "strings" "botpontos/internal/domain" "botpontos/internal/rules" ) // Register cria o pedido de cadastro pelo /cadastrar (idempotente) e avisa os admins. func (s *Service) Register(ctx context.Context, chatID int64, name string) (domain.User, bool, error) { name = strings.TrimSpace(name) if name == "" { name = "Sem nome" } u, created, err := s.store.CreatePendingUser(ctx, chatID, name, s.cfg.DefaultTimezone) if err != nil { return u, false, err } if created { s.audit(ctx, TelegramActor(u.ID), &u.ID, domain.EventUserRegistered, true, map[string]any{"name": u.DisplayName}) s.notifyAdmins(ctx, msgNewRegistration(u)) } return u, created, nil } func (s *Service) UserByChatID(ctx context.Context, chatID int64) (domain.User, error) { return s.store.GetUserByChatID(ctx, chatID) } // requireValidatedCredential é a regra única de ativação (Telegram e painel, 9.2). func (s *Service) requireValidatedCredential(ctx context.Context, userID int64) (domain.Credential, error) { cred, err := s.store.GetCredential(ctx, userID) if errors.Is(err, domain.ErrNotFound) || (err == nil && !cred.IsValidated()) { return cred, domain.ErrCredentialsRequired } return cred, err } // ActivateUser aprova um cadastro. Exige credencial validada na PontoMais. func (s *Service) ActivateUser(ctx context.Context, actor Actor, userID int64) (domain.User, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return u, err } if u.IsActive() { return u, nil } if _, err := s.requireValidatedCredential(ctx, userID); err != nil { s.audit(ctx, actor, &userID, domain.EventUserActivated, false, map[string]any{"reason": "credencial ausente ou não validada"}) return u, err } if err := s.store.SetUserStatus(ctx, userID, domain.UserActive); err != nil { return u, err } u.Status = domain.UserActive s.audit(ctx, actor, &userID, domain.EventUserActivated, true, nil) s.notifyUser(ctx, u, msgActivated) return u, nil } // DisableUser bloqueia o usuário, desliga os lembretes e cancela os agendados. func (s *Service) DisableUser(ctx context.Context, actor Actor, userID int64) error { u, err := s.store.GetUser(ctx, userID) if err != nil { return err } if err := s.store.SetUserStatus(ctx, userID, domain.UserDisabled); err != nil { return err } if u.NotificationsEnabled { reason := domain.PauseUserDisabled if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil { return err } } cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location())) if err != nil { return err } s.audit(ctx, actor, &userID, domain.EventUserDisabled, true, map[string]any{"cancelled_notifications": cancelled}) if u.IsActive() { s.notifyUser(ctx, u, msgDisabledByAdmin) } return nil } // EnableNotifications liga os lembretes (/ativarponto ou painel), zera o // contador de falhas e retoma o dia se ele tinha sido encerrado sem pontos. func (s *Service) EnableNotifications(ctx context.Context, actor Actor, userID int64) (domain.User, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return u, err } if !u.IsActive() { return u, domain.ErrUserNotActive } if _, err := s.requireValidatedCredential(ctx, userID); err != nil { return u, err } if err := s.store.SetNotifications(ctx, userID, true, nil); err != nil { return u, err } if err := s.store.ResetCredentialFailures(ctx, userID); err != nil { return u, err } u.NotificationsEnabled, u.PausedReason = true, nil s.audit(ctx, actor, &userID, domain.EventNotificationsEnabled, true, nil) source := domain.ResumeAtivarPonto if actor.Source == domain.SourceWeb { source = domain.ResumeWeb } if _, err := s.resumeDayIfClosed(ctx, actor, u, source); err != nil && !errors.Is(err, errNothingToResume) { s.log.Warn("falha ao retomar o dia ao ligar lembretes", "user_id", userID, "error", err) } return u, nil } // DisableNotifications pausa os lembretes e cancela os agendados do dia em diante. func (s *Service) DisableNotifications(ctx context.Context, actor Actor, userID int64, reason domain.PauseReason) error { u, err := s.store.GetUser(ctx, userID) if err != nil { return err } if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil { return err } cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location())) if err != nil { return err } s.audit(ctx, actor, &userID, domain.EventNotificationsDisabled, true, map[string]any{"reason": string(reason), "cancelled_notifications": cancelled}) return nil } func (s *Service) SetBotAdmin(ctx context.Context, actor Actor, userID int64, enabled bool) error { if _, err := s.store.GetUser(ctx, userID); err != nil { return err } if err := s.store.SetBotAdmin(ctx, userID, enabled); err != nil { return err } s.audit(ctx, actor, &userID, domain.EventBotAdminChanged, true, map[string]any{"enabled": enabled}) return nil } // ScheduleInput são os campos editáveis da jornada no painel. type ScheduleInput struct { LunchMinutes int DailyWorkMinutes int Entrada2LeadMinutes int Saida2LeadMinutes int Timezone string Window *domain.QueryWindow // nil = usa a janela global } // UpdateSchedule valida (9.8, 9.13, 9.14) e grava a jornada e a exceção de janela. func (s *Service) UpdateSchedule(ctx context.Context, actor Actor, userID int64, in ScheduleInput) (domain.WorkSchedule, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return domain.WorkSchedule{}, err } global, err := s.GlobalWindow(ctx) if err != nil { return domain.WorkSchedule{}, err } next := domain.WorkSchedule{ UserID: userID, LunchMinutes: in.LunchMinutes, DailyWorkMinutes: in.DailyWorkMinutes, Entrada2LeadMinutes: in.Entrada2LeadMinutes, Saida2LeadMinutes: in.Saida2LeadMinutes, Window: in.Window, } errs := rules.ValidateSchedule(next, global, s.cfg.Jobs.ReconciliationInterval) var tzErrs domain.ValidationErrors if err := rules.ValidateTimezone(in.Timezone); errors.As(err, &tzErrs) { errs = append(errs, tzErrs...) } if len(errs) > 0 { return next, errs } previous, err := s.store.GetSchedule(ctx, userID) if err != nil { return next, err } if err := s.store.SaveSchedule(ctx, next); err != nil { return next, err } if in.Timezone != u.Timezone { if err := s.store.SetTimezone(ctx, userID, in.Timezone); err != nil { return next, err } } s.audit(ctx, actor, &userID, domain.EventScheduleChanged, true, map[string]any{ "before": scheduleDetails(previous, u.Timezone), "after": scheduleDetails(next, in.Timezone), }) if windowText(previous.Window) != windowText(next.Window) { s.audit(ctx, actor, &userID, domain.EventUserWindowChanged, true, map[string]any{ "before": windowText(previous.Window), "after": windowText(next.Window), }) s.recheckToday(ctx, u) } return next, nil } // recheckToday antecipa a próxima consulta do dia, para a nova janela valer no próximo tick. func (s *Service) recheckToday(ctx context.Context, u domain.User) { day := domain.LocalDay(s.now(), u.Location()) d, found, err := s.store.GetUserDay(ctx, u.ID, day) if err != nil || !found || d.NextCheckAt == nil { return } now := s.now() d.NextCheckAt = &now if err := s.store.SaveUserDay(ctx, d); err != nil { s.log.Warn("falha ao recalcular a próxima consulta", "user_id", u.ID, "error", err) } } func scheduleDetails(sch domain.WorkSchedule, timezone string) map[string]any { return map[string]any{ "lunch_minutes": sch.LunchMinutes, "daily_work_minutes": sch.DailyWorkMinutes, "entrada2_lead_minutes": sch.Entrada2LeadMinutes, "saida2_lead_minutes": sch.Saida2LeadMinutes, "timezone": timezone, "window": windowText(sch.Window), } } func windowText(w *domain.QueryWindow) string { if w == nil { return "global" } return fmt.Sprintf("%s–%s (confirmação %s, reverificações até %s)", w.Start, w.End, w.DayCheckDeadline, w.NoPointsRecheckUntil) } // CredentialError indica que a PontoMais não aceitou a credencial informada. type CredentialError struct{ Err error } func (e *CredentialError) Error() string { return "a PontoMais não aceitou a credencial: " + e.Err.Error() } func (e *CredentialError) Unwrap() error { return e.Err } // SetCredentials valida a credencial na PontoMais e, só se der certo, cifra e // substitui a anterior (que é preservada em caso de falha). func (s *Service) SetCredentials(ctx context.Context, actor Actor, userID int64, email, password string) error { email = strings.TrimSpace(email) var errs domain.ValidationErrors if email == "" { errs.Add("email", "obrigatório") } if password == "" { errs.Add("password", "obrigatória") } if len(errs) > 0 { return errs } u, err := s.store.GetUser(ctx, userID) if err != nil { return err } if err := s.points.Validate(ctx, email, password, u.Location()); err != nil { s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, false, map[string]any{"error": shortError(err)}) return &CredentialError{Err: err} } encrypted, version, err := s.cipher.Encrypt(password) if err != nil { return err } if err := s.store.SaveCredential(ctx, userID, email, encrypted, version, s.now()); err != nil { return err } s.points.Invalidate(userID) s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, true, map[string]any{"email": email}) return nil } // UserDetail reúne o que a tela do usuário mostra (a senha nunca é exposta). type UserDetail struct { User domain.User Schedule domain.WorkSchedule Credential *domain.Credential Today domain.UserDay Window domain.QueryWindow CustomWindow bool } func (s *Service) UserDetail(ctx context.Context, userID int64) (UserDetail, error) { u, err := s.store.GetUser(ctx, userID) if err != nil { return UserDetail{}, err } schedule, err := s.store.GetSchedule(ctx, userID) if err != nil { return UserDetail{}, err } detail := UserDetail{User: u, Schedule: schedule} if cred, err := s.store.GetCredential(ctx, userID); err == nil { cred.EncryptedPassword = nil detail.Credential = &cred } else if !errors.Is(err, domain.ErrNotFound) { return detail, err } if detail.Today, _, err = s.store.GetUserDay(ctx, userID, domain.LocalDay(s.now(), u.Location())); err != nil { return detail, err } global, err := s.GlobalWindow(ctx) if err != nil { return detail, err } detail.Window, detail.CustomWindow = rules.EffectiveWindow(global, schedule) return detail, nil }