324 lines
10 KiB
Go
324 lines
10 KiB
Go
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
|
||
}
|