Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View 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
}