185 lines
5.6 KiB
Go
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] + "…"
|
|
}
|