Files
2026-10-23 22:31:02 -03:00

324 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}