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