// Package app contém os casos de uso. É chamado pelo bot do Telegram, pela // API HTTP, pela CLI e pelos jobs do worker, de modo que a mesma regra vale // para todas as interfaces (ex.: ativação exige credencial validada). package app import ( "context" "log/slog" "sync" "time" "unicode/utf8" "botpontos/internal/adapters/postgres" "botpontos/internal/config" "botpontos/internal/domain" "botpontos/internal/metrics" ) // PointsProvider é o port da PontoMais (adapter: pontomais.Client). type PointsProvider interface { History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) Validate(ctx context.Context, email, password string, loc *time.Location) error Invalidate(userID int64) } // Messenger é o port do Telegram (adapter: telegram.Client). type Messenger interface { Send(ctx context.Context, chatID int64, text string) (int64, error) } // HolidayProvider é o port da API de feriados (adapter: feriados.Client). type HolidayProvider interface { FetchYear(ctx context.Context, year int) ([]domain.Holiday, error) } // Cipher é o port da criptografia das senhas PontoMais (adapter: crypto.Keyring). type Cipher interface { Encrypt(plain string) ([]byte, int16, error) Decrypt(data []byte, version int16) (string, error) CurrentVersion() int16 } type Deps struct { Store *postgres.Store Points PointsProvider // obrigatório para api e worker Messenger Messenger // pode estar sem token (envios falham com ErrNotConfigured) Holidays HolidayProvider // opcional: sem ele vale o fallback de segunda a sexta Cipher Cipher Config config.Config Logger *slog.Logger Metrics metrics.Recorder // opcional: sem ele, metrics.Noop Now func() time.Time // opcional (testes) } type Service struct { store *postgres.Store points PointsProvider messenger Messenger holidays HolidayProvider cipher Cipher cfg config.Config log *slog.Logger metrics metrics.Recorder now func() time.Time location *time.Location // fuso padrão (configurações globais, retenção) mu sync.Mutex syncedYears map[int]bool syncTried map[int]string // ano → último dia em que a sincronização foi tentada } func New(d Deps) *Service { now := d.Now if now == nil { now = time.Now } var recorder metrics.Recorder = metrics.Noop{} if d.Metrics != nil { recorder = d.Metrics } return &Service{ store: d.Store, points: d.Points, messenger: d.Messenger, holidays: d.Holidays, cipher: d.Cipher, cfg: d.Config, log: d.Logger, metrics: recorder, now: now, location: domain.LoadLocation(d.Config.DefaultTimezone), syncedYears: map[int]bool{}, syncTried: map[int]string{}, } } // Store expõe o repositório para leituras simples da API (listas e detalhes). func (s *Service) Store() *postgres.Store { return s.store } func (s *Service) Config() config.Config { return s.cfg } func (s *Service) Now() time.Time { return s.now() } // Location é o fuso padrão da instalação. func (s *Service) Location() *time.Location { return s.location } // TodayDate devolve a data de hoje (AAAA-MM-DD) no fuso padrão. func (s *Service) TodayDate() string { return domain.LocalDay(s.now(), s.location) } // Actor identifica quem executou uma ação, para a auditoria. type Actor struct { Source domain.AuditSource UserID *int64 AccountID *int64 } func TelegramActor(userID int64) Actor { return Actor{Source: domain.SourceTelegram, UserID: &userID} } func WebActor(accountID int64) Actor { return Actor{Source: domain.SourceWeb, AccountID: &accountID} } var ( WorkerActor = Actor{Source: domain.SourceWorker} CLIActor = Actor{Source: domain.SourceCLI} ) // audit grava um evento; uma falha na auditoria é registrada no log e não interrompe a operação. func (s *Service) audit(ctx context.Context, actor Actor, target *int64, event string, success bool, details map[string]any) { err := s.store.InsertAudit(ctx, domain.AuditEntry{ Source: actor.Source, ActorUserID: actor.UserID, ActorAccount: actor.AccountID, TargetUserID: target, EventType: event, Success: success, Details: details, }) if err != nil { s.log.Error("falha ao gravar auditoria", "event", event, "error", err) } } // notifyAdmins envia um aviso a todos os admins ativos do bot (9.11). func (s *Service) notifyAdmins(ctx context.Context, text string) { admins, err := s.store.ListActiveBotAdmins(ctx) if err != nil { s.log.Error("falha ao listar admins para aviso", "error", err) return } if len(admins) == 0 { s.log.Warn("nenhum admin ativo no Telegram para receber o aviso", "text", text) return } for _, admin := range admins { if _, err := s.messenger.Send(ctx, admin.TelegramChatID, text); err != nil { s.log.Warn("falha ao avisar admin", "user_id", admin.ID, "error", err) } } } // notifyUser envia uma mensagem avulsa ao usuário; falhas só vão para o log. func (s *Service) notifyUser(ctx context.Context, u domain.User, text string) { if _, err := s.messenger.Send(ctx, u.TelegramChatID, text); err != nil { s.log.Warn("falha ao enviar mensagem ao usuário", "user_id", u.ID, "error", err) } } func ptr[T any](v T) *T { return &v } // shortError limita o tamanho de mensagens de erro gravadas no banco. func shortError(err error) string { return truncate(err.Error(), 300) } // truncate corta em até max bytes sem quebrar um caractere UTF-8 (o Postgres // recusa texto com bytes inválidos). func truncate(s string, max int) string { if len(s) <= max { return s } n := max for n > 0 && !utf8.RuneStart(s[n]) { n-- } return s[:n] + "…" }