Files
bot-pontos/backend/internal/app/service.go
2026-10-23 22:31:02 -03:00

185 lines
5.6 KiB
Go

// 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] + "…"
}