Projeto bot pontos
This commit is contained in:
323
backend/internal/app/users.go
Normal file
323
backend/internal/app/users.go
Normal file
@@ -0,0 +1,323 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user