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,141 @@
package app
import (
"context"
"errors"
"strings"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
const stateHolidayFallbackNotified = "holiday_fallback_notified"
// IsBusinessDay aplica a ordem da seção 9.9: fim de semana → tabela holidays
// (com sincronização anual da API) → fallback de segunda a sexta com aviso.
func (s *Service) IsBusinessDay(ctx context.Context, t time.Time, loc *time.Location) (bool, error) {
if rules.IsWeekend(t, loc) {
return false, nil
}
local := t.In(loc)
s.ensureHolidays(ctx, local.Year())
holiday, err := s.store.IsHoliday(ctx, local.Format(domain.DateLayout))
if err != nil {
// Sem banco não há como saber; mantém a automação funcionando.
return true, err
}
return !holiday, nil
}
// ensureHolidays sincroniza o ano com a API na primeira vez e, enquanto não
// der certo, tenta no máximo uma vez por dia (9.9).
func (s *Service) ensureHolidays(ctx context.Context, year int) {
today := s.TodayDate()
s.mu.Lock()
if s.syncedYears[year] || s.syncTried[year] == today {
s.mu.Unlock()
return
}
s.syncTried[year] = today
s.mu.Unlock()
synced, err := s.store.HasSyncedHolidays(ctx, year)
if err == nil && synced {
s.markSynced(year)
return
}
if _, err := s.syncHolidays(ctx, WorkerActor, year); err != nil {
s.holidayFallback(ctx, year, err)
return
}
s.markSynced(year)
}
func (s *Service) markSynced(year int) {
s.mu.Lock()
s.syncedYears[year] = true
s.mu.Unlock()
}
// holidayFallback avisa os admins no máximo uma vez por dia (9.9 e 9.11).
func (s *Service) holidayFallback(ctx context.Context, year int, cause error) {
today := s.TodayDate()
var state struct {
Day string `json:"day"`
}
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &state); found && state.Day == today {
return
}
state.Day = today
if err := s.store.SetState(ctx, stateHolidayFallbackNotified, state); err != nil {
s.log.Warn("falha ao registrar aviso de feriados", "error", err)
}
s.audit(ctx, WorkerActor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(cause)})
s.notifyAdmins(ctx, msgHolidayFallback(year, cause))
}
// SyncHolidays baixa os feriados do ano (worker ou botão do painel).
func (s *Service) SyncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
inserted, err := s.syncHolidays(ctx, actor, year)
if err == nil {
s.markSynced(year)
}
return inserted, err
}
func (s *Service) syncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
if s.holidays == nil {
return 0, domain.ErrNotConfigured
}
holidays, err := s.holidays.FetchYear(ctx, year)
if err != nil {
if actor.Source != domain.SourceWorker {
s.audit(ctx, actor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(err)})
}
return 0, err
}
inserted, err := s.store.InsertHolidays(ctx, holidays)
if err != nil {
return 0, err
}
s.audit(ctx, actor, nil, domain.EventHolidaySynced, true, map[string]any{"year": year, "received": len(holidays), "inserted": inserted})
return inserted, nil
}
func (s *Service) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) {
return s.store.ListHolidays(ctx, year)
}
func (s *Service) AddHoliday(ctx context.Context, actor Actor, day, name string) error {
name = strings.TrimSpace(name)
var errs domain.ValidationErrors
if _, err := time.Parse(domain.DateLayout, day); err != nil {
errs.Add("day", "data inválida (use AAAA-MM-DD)")
}
if name == "" || len(name) > 120 {
errs.Add("name", "obrigatório, até 120 caracteres")
}
if len(errs) > 0 {
return errs
}
err := s.store.AddHoliday(ctx, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceManual})
if errors.Is(err, domain.ErrConflict) {
return domain.ValidationErrors{{Field: "day", Message: "já existe um feriado nessa data"}}
}
if err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventHolidayAdded, true, map[string]any{"day": day, "name": name})
return nil
}
func (s *Service) DeleteHoliday(ctx context.Context, actor Actor, day string) error {
if err := s.store.DeleteManualHoliday(ctx, day); err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventHolidayRemoved, true, map[string]any{"day": day})
return nil
}

View File

@@ -0,0 +1,151 @@
package app
import (
"context"
"errors"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
// Dispatch é o ciclo de 2 min (6.3): expira o que passou da tolerância e
// envia o que venceu, reservando cada lembrete sem duplicidade.
func (s *Service) Dispatch(ctx context.Context) error {
now := s.now()
tolerance := s.cfg.Jobs.LateTolerance
expired, err := s.store.ExpireOverdue(ctx, now.Add(-tolerance))
if err != nil {
return err
}
for _, n := range expired {
s.metrics.Notification(string(n.Type), string(domain.NotificationExpired))
s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{
"type": string(n.Type), "delay_minutes": int(now.Sub(n.ScheduledAt).Minutes()),
})
}
claimed, err := s.store.ClaimDue(ctx, now, 100)
if err != nil {
return err
}
for _, n := range claimed {
// A tolerância é conferida de novo entre a reserva e o envio.
if delay := s.now().Sub(n.ScheduledAt); delay > tolerance {
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationExpired, "atraso acima da tolerância")
s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{
"type": string(n.Type), "delay_minutes": int(delay.Minutes()),
})
continue
}
u, err := s.store.GetUser(ctx, n.UserID)
if err != nil {
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err))
continue
}
if !u.IsActive() || !u.NotificationsEnabled {
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationCancelled, "lembretes desligados")
continue
}
if err := s.deliver(ctx, n, u); err != nil {
s.log.Warn("lembrete não enviado", "notification_id", n.ID, "error", err)
}
}
return nil
}
// deliver envia um lembrete já reservado (sending) e registra o resultado.
func (s *Service) deliver(ctx context.Context, n domain.Notification, u domain.User) error {
schedule, err := s.store.GetSchedule(ctx, u.ID)
if err != nil {
schedule = domain.DefaultSchedule(u.ID)
}
text := msgReminder(n, u.Location(), schedule, s.now())
messageID, err := s.messenger.Send(ctx, u.TelegramChatID, text)
if err != nil {
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err))
s.metrics.Notification(string(n.Type), string(domain.NotificationFailed))
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationFailed, false,
map[string]any{"type": string(n.Type), "error": shortError(err)})
return err
}
if err := s.store.MarkSent(ctx, n.ID, messageID, s.now()); err != nil {
return err
}
s.metrics.Notification(string(n.Type), string(domain.NotificationSent))
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationSent, true, map[string]any{"type": string(n.Type)})
return nil
}
// CancelNotification cancela um lembrete agendado pelo painel.
func (s *Service) CancelNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) {
n, err := s.store.CancelNotification(ctx, id)
if errors.Is(err, domain.ErrNotFound) {
if _, getErr := s.store.GetNotification(ctx, id); getErr == nil {
return n, domain.InvalidState("só é possível cancelar lembretes agendados")
}
return n, domain.ErrNotFound
}
if err != nil {
return n, err
}
s.audit(ctx, actor, &n.UserID, domain.EventNotificationCancelled, true,
map[string]any{"notification_id": id, "type": string(n.Type)})
return n, nil
}
// ResendNotification reenvia manualmente um lembrete failed do dia (9.5).
func (s *Service) ResendNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) {
n, err := s.store.GetNotification(ctx, id)
if err != nil {
return n, err
}
if n.Status != domain.NotificationFailed {
return n, domain.InvalidState("só é possível reenviar lembretes com falha")
}
u, err := s.store.GetUser(ctx, n.UserID)
if err != nil {
return n, err
}
if n.ReferenceDate != domain.LocalDay(s.now(), u.Location()) {
return n, domain.InvalidState("só é possível reenviar lembretes do dia atual")
}
if !u.IsActive() || !u.NotificationsEnabled {
return n, domain.InvalidState("o usuário precisa estar ativo e com os lembretes ligados")
}
// Bloqueia se o ponto seguinte já foi batido.
cred, err := s.requireValidatedCredential(ctx, u.ID)
if err != nil {
return n, err
}
points, err := s.points.History(ctx, cred, u.Location())
if err != nil {
return n, domain.InvalidState("não foi possível conferir os pontos na PontoMais: " + shortError(err))
}
wd := rules.CurrentWorkday(points, n.ReferenceDate, u.Location())
if (n.Type == domain.NotificationEntrada2 && wd.Count() >= 3) || (n.Type == domain.NotificationSaida2 && wd.Count() >= 4) {
return n, domain.InvalidState("o ponto seguinte já foi registrado; o lembrete não é mais necessário")
}
claimed, err := s.store.ClaimFailed(ctx, id)
if errors.Is(err, domain.ErrNotFound) {
return n, domain.InvalidState("o lembrete já foi reenviado por outra pessoa")
}
if err != nil {
return n, err
}
sendErr := s.deliver(ctx, claimed, u)
s.audit(ctx, actor, &u.ID, domain.EventNotificationResent, sendErr == nil,
map[string]any{"notification_id": id, "type": string(n.Type)})
updated, err := s.store.GetNotification(ctx, id)
if err != nil {
return claimed, err
}
if sendErr != nil {
return updated, domain.InvalidState("o reenvio falhou: " + shortError(sendErr))
}
return updated, nil
}

View File

@@ -0,0 +1,124 @@
package app
import (
"context"
"botpontos/internal/adapters/postgres"
"botpontos/internal/domain"
)
const stateRetentionLastRun = "retention_last_run"
// Maintenance é o ciclo de 5 min: falha os envios travados e, uma vez por dia
// no horário configurado, aplica a retenção de dados (9.10).
func (s *Service) Maintenance(ctx context.Context) error {
stuck, err := s.store.FailStuckSending(ctx, s.now().Add(-s.cfg.Jobs.SendingTimeout))
if err != nil {
return err
}
for _, n := range stuck {
s.audit(ctx, WorkerActor, &n.UserID, domain.EventInterruptedSending, false,
map[string]any{"notification_id": n.ID, "type": string(n.Type)})
}
return s.applyRetentionIfDue(ctx)
}
func (s *Service) applyRetentionIfDue(ctx context.Context) error {
now := s.now().In(s.location)
if now.Hour() != s.cfg.Jobs.RetentionHour {
return nil
}
today := s.TodayDate()
var last struct {
Day string `json:"day"`
}
if found, err := s.store.GetState(ctx, stateRetentionLastRun, &last); err != nil {
return err
} else if found && last.Day == today {
return nil
}
audit, err := s.store.DeleteAuditBefore(ctx, now.Add(-s.cfg.Jobs.RetentionAudit))
if err != nil {
return err
}
notificationsCutoff := domain.LocalDay(now.Add(-s.cfg.Jobs.RetentionNotifications), s.location)
notifications, err := s.store.DeleteNotificationsBefore(ctx, notificationsCutoff)
if err != nil {
return err
}
days, err := s.store.DeleteUserDaysBefore(ctx, notificationsCutoff)
if err != nil {
return err
}
sessions, err := s.store.DeleteSessionsEndedBefore(ctx, now.Add(-s.cfg.Jobs.RetentionSessions))
if err != nil {
return err
}
last.Day = today
if err := s.store.SetState(ctx, stateRetentionLastRun, last); err != nil {
return err
}
s.audit(ctx, WorkerActor, nil, domain.EventRetentionApplied, true, map[string]any{
"audit_logs": audit, "notifications": notifications, "user_days": days, "web_sessions": sessions,
})
return nil
}
// RotateKey recifra com a chave atual as credenciais gravadas com outra versão.
func (s *Service) RotateKey(ctx context.Context) (int, error) {
creds, err := s.store.ListCredentials(ctx)
if err != nil {
return 0, err
}
current := s.cipher.CurrentVersion()
rotated := 0
for _, c := range creds {
if c.KeyVersion == current {
continue
}
plain, err := s.cipher.Decrypt(c.EncryptedPassword, c.KeyVersion)
if err != nil {
return rotated, err
}
encrypted, version, err := s.cipher.Encrypt(plain)
if err != nil {
return rotated, err
}
if err := s.store.UpdateCredentialCipher(ctx, c.UserID, encrypted, version); err != nil {
return rotated, err
}
rotated++
}
s.audit(ctx, CLIActor, nil, domain.EventEncryptionKeyRotated, true, map[string]any{"credentials": rotated, "version": current})
return rotated, nil
}
// Overview reúne os dados do dashboard do painel.
type Overview struct {
Counts postgres.Dashboard
OutageSince *string
HolidayFallback *string
}
func (s *Service) Overview(ctx context.Context) (Overview, error) {
var o Overview
counts, err := s.store.Dashboard(ctx, s.TodayDate())
if err != nil {
return o, err
}
o.Counts = counts
var outage outageState
if found, _ := s.store.GetState(ctx, stateOutage, &outage); found {
since := outage.Since.Format("2006-01-02T15:04:05Z07:00")
o.OutageSince = &since
}
var fallback struct {
Day string `json:"day"`
}
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &fallback); found && fallback.Day == s.TodayDate() {
o.HolidayFallback = &fallback.Day
}
return o, nil
}

View File

@@ -0,0 +1,78 @@
package app
import (
"fmt"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
// Textos enviados pelo Telegram. Ficam juntos para facilitar a revisão.
func msgNewRegistration(u domain.User) string {
return fmt.Sprintf("Novo pedido de cadastro: %s (ID %d).\n"+
"Configure a credencial da PontoMais no painel e ative com /ativarusuario %d ou pelo painel.",
u.DisplayName, u.ID, u.ID)
}
const msgActivated = "Seu cadastro foi aprovado. Envie /ativarponto para ligar os lembretes e /ajuda para ver os comandos."
const msgDisabledByAdmin = "Seu acesso ao Bot de Pontos foi desativado por um administrador."
func msgPausedAPIFailures(failures int) string {
return fmt.Sprintf("Não consegui consultar a PontoMais %d vezes seguidas, então pausei seus lembretes.\n"+
"Confira com o administrador se sua credencial está correta e depois envie /ativarponto.", failures)
}
func msgAdminUserPaused(u domain.User, failures int, err error) string {
return fmt.Sprintf("Lembretes de %s (ID %d) pausados após %d falhas seguidas na PontoMais.\nÚltimo erro: %s",
u.DisplayName, u.ID, failures, shortError(err))
}
func msgOutageStarted(users int, err error) string {
return fmt.Sprintf("A PontoMais parece fora do ar: as consultas de %d usuários falharam no mesmo ciclo.\n"+
"Ninguém foi pausado. Último erro: %s", users, shortError(err))
}
func msgOutageEnded(duration time.Duration) string {
return fmt.Sprintf("A PontoMais voltou a responder. A queda durou cerca de %s.", formatDuration(duration))
}
func msgHolidayFallback(year int, err error) string {
return fmt.Sprintf("Não consegui obter os feriados de %d (%s).\n"+
"Enquanto isso, segunda a sexta contam como dias úteis. Cadastre feriados manualmente no painel se necessário.",
year, shortError(err))
}
func msgScheduleViolations(names []string, interval time.Duration) string {
return fmt.Sprintf("Estes usuários têm almoço − antecedência menor que o intervalo de reconciliação (%s) e podem receber "+
"o lembrete de retorno atrasado: %v. Ajuste a jornada no painel.", formatDuration(interval), names)
}
// msgReminder é o lembrete automático; sempre informa o horário previsto (9.3).
func msgReminder(n domain.Notification, loc *time.Location, schedule domain.WorkSchedule, now time.Time) string {
clock := rules.FormatClock(n.PredictedAt, loc)
late := now.After(n.PredictedAt)
switch n.Type {
case domain.NotificationEntrada2:
if late {
return "⏰ Lembrete: seu retorno do almoço estava previsto para " + clock + "."
}
return "⏰ Lembrete: seu retorno do almoço está previsto para " + clock + "."
default:
jornada := rules.FormatMinutes(schedule.DailyWorkMinutes)
if late {
return "⏰ Lembrete: sua saída estava prevista para " + clock + " (jornada de " + jornada + ")."
}
return "⏰ Lembrete: sua saída está prevista para " + clock + " (jornada de " + jornada + ")."
}
}
func formatDuration(d time.Duration) string {
minutes := int(d.Round(time.Minute) / time.Minute)
if minutes < 60 {
return fmt.Sprintf("%d min", minutes)
}
return rules.FormatMinutes(minutes)
}

View File

@@ -0,0 +1,380 @@
package app
import (
"context"
"errors"
"sync"
"time"
"botpontos/internal/adapters/postgres"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
const stateOutage = "pontomais_outage"
type outageState struct {
Since time.Time `json:"since"`
LastError string `json:"last_error"`
}
// reconJob é um usuário que deve ser consultado neste tick.
type reconJob struct {
ru postgres.ReconUser
loc *time.Location
day string
window domain.QueryWindow
state domain.UserDay
}
type reconResult struct {
job reconJob
points []domain.Point
err error
skipped bool // não chegou a ser consultado (prazo do ciclo esgotado)
}
// Reconcile é o ciclo de 15 min (8.1, 8.2, 9.4, 9.12): seleciona quem está
// na janela e com a próxima consulta vencida, consulta a PontoMais com
// concorrência limitada e aplica as regras.
func (s *Service) Reconcile(ctx context.Context, tick time.Time) error {
global, err := s.GlobalWindow(ctx)
if err != nil {
return err
}
users, err := s.store.ListReconciliationUsers(ctx)
if err != nil {
return err
}
var due []reconJob
for _, ru := range users {
loc := ru.User.Location()
day := domain.LocalDay(tick, loc)
window, _ := rules.EffectiveWindow(global, ru.Schedule)
if !rules.InWindow(tick, window, day, loc) {
continue
}
if business, _ := s.IsBusinessDay(ctx, tick, loc); !business {
continue
}
state, found, err := s.store.GetUserDay(ctx, ru.User.ID, day)
if err != nil {
return err
}
if found && (state.NextCheckAt == nil || state.NextCheckAt.After(tick)) {
continue
}
due = append(due, reconJob{ru: ru, loc: loc, day: day, window: window, state: state})
}
if len(due) == 0 {
return nil
}
started := s.now()
results := s.fetchAll(ctx, due)
var successes, failures []reconResult
for _, r := range results {
switch {
case r.skipped:
case r.err == nil || errors.Is(r.err, domain.ErrInvalidPointHistory):
successes = append(successes, r) // a API respondeu (mesmo com dado inválido)
default:
failures = append(failures, r)
}
}
var outage outageState
outageActive, err := s.store.GetState(ctx, stateOutage, &outage)
if err != nil {
s.log.Warn("falha ao ler estado de queda", "error", err)
}
attempted := len(successes) + len(failures)
s.metrics.ReconciliationCycle(s.now().Sub(started), attempted, len(failures))
switch {
case attempted >= 2 && len(successes) == 0:
// Todos falharam: queda da PontoMais. Ninguém soma falha (9.4).
if !outageActive {
s.startOutage(ctx, len(failures), failures[0].err)
}
return nil
case outageActive && len(successes) == 0:
// Queda em andamento e só um usuário consultado: não conta.
return nil
case outageActive:
s.endOutage(ctx, outage)
}
for _, r := range successes {
if r.err != nil {
s.handleInvalidHistory(ctx, r.job, tick, r.err)
continue
}
if err := s.applyPoints(ctx, r.job, r.points, tick); err != nil {
s.log.Error("falha ao aplicar pontos", "user_id", r.job.ru.User.ID, "error", err)
}
}
for _, r := range failures {
s.recordFailure(ctx, r.job, r.err)
}
return nil
}
func (s *Service) fetchAll(ctx context.Context, jobs []reconJob) []reconResult {
deadline, cancel := context.WithTimeout(ctx, s.cfg.Jobs.ReconciliationDeadline)
defer cancel()
results := make([]reconResult, len(jobs))
sem := make(chan struct{}, s.cfg.Jobs.ReconciliationWorkers)
var wg sync.WaitGroup
for i, job := range jobs {
wg.Add(1)
go func(i int, job reconJob) {
defer wg.Done()
results[i].job = job
select {
case sem <- struct{}{}:
case <-deadline.Done():
results[i].skipped = true
return
}
defer func() { <-sem }()
requestStart := time.Now()
points, err := s.points.History(deadline, job.ru.Credential, job.loc)
s.metrics.PointsRequest(time.Since(requestStart), err)
if err != nil && deadline.Err() != nil && ctx.Err() == nil {
// Prazo do ciclo esgotado: fica para o próximo tick, sem contar falha.
results[i].skipped = true
return
}
results[i].points, results[i].err = points, err
}(i, job)
}
wg.Wait()
return results
}
func (s *Service) startOutage(ctx context.Context, users int, cause error) {
state := outageState{Since: s.now(), LastError: shortError(cause)}
if err := s.store.SetState(ctx, stateOutage, state); err != nil {
s.log.Warn("falha ao registrar queda", "error", err)
}
s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageStarted, false, map[string]any{"users": users, "error": state.LastError})
s.notifyAdmins(ctx, msgOutageStarted(users, cause))
}
func (s *Service) endOutage(ctx context.Context, outage outageState) {
if err := s.store.DeleteState(ctx, stateOutage); err != nil {
s.log.Warn("falha ao encerrar queda", "error", err)
}
duration := s.now().Sub(outage.Since)
s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageEnded, true, map[string]any{"minutes": int(duration.Minutes())})
s.notifyAdmins(ctx, msgOutageEnded(duration))
}
// recordFailure aplica a regra das 3 falhas seguidas (9.4). O usuário volta a
// ser consultado no próximo tick, porque o estado do dia não muda.
func (s *Service) recordFailure(ctx context.Context, job reconJob, cause error) {
u := job.ru.User
failures, err := s.store.RecordCredentialFailure(ctx, u.ID, shortError(cause), s.now())
if err != nil {
s.log.Error("falha ao registrar falha da PontoMais", "user_id", u.ID, "error", err)
return
}
s.audit(ctx, WorkerActor, &u.ID, domain.EventPointAPIFailure, false,
map[string]any{"consecutive_failures": failures, "error": shortError(cause)})
if failures < s.cfg.Jobs.APIFailureThreshold {
return
}
reason := domain.PauseAPIFailures
if err := s.store.SetNotifications(ctx, u.ID, false, &reason); err != nil {
s.log.Error("falha ao pausar lembretes", "user_id", u.ID, "error", err)
return
}
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationsPausedAPI, false, map[string]any{"consecutive_failures": failures})
s.notifyUser(ctx, u, msgPausedAPIFailures(failures))
s.notifyAdmins(ctx, msgAdminUserPaused(u, failures, cause))
}
// handleInvalidHistory: a API respondeu com dado ilegível. Não conta como falha;
// consulta de novo mais devagar.
func (s *Service) handleInvalidHistory(ctx context.Context, job reconJob, tick time.Time, cause error) {
u := job.ru.User
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false, map[string]any{"error": shortError(cause)})
next := tick.Add(s.cfg.Jobs.SlowCheckInterval)
state := job.state
if next.After(job.window.End.On(job.day, job.loc)) {
state.NextCheckAt = nil
} else {
state.NextCheckAt = &next
}
if err := s.store.SaveUserDay(ctx, state); err != nil {
s.log.Error("falha ao salvar o dia", "user_id", u.ID, "error", err)
}
}
// applyPoints cria, mantém ou cancela os lembretes e calcula a próxima consulta.
func (s *Service) applyPoints(ctx context.Context, job reconJob, points []domain.Point, tick time.Time) error {
u, schedule := job.ru.User, job.ru.Schedule
if job.ru.Credential.ConsecutiveFailures > 0 || job.ru.Credential.LastError != nil {
if err := s.store.ResetCredentialFailures(ctx, u.ID); err != nil {
return err
}
}
wd := rules.CurrentWorkday(points, job.day, job.loc)
previous := job.state
var saida2At *time.Time
switch {
case wd.Invalid:
if previous.LastPointCount != wd.Count() || previous.UpdatedAt.IsZero() {
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false,
map[string]any{"reason": "pontos repetidos ou fora de ordem", "points": wd.Count()})
}
case wd.Overtime || wd.Count() >= 4:
cancelled, err := s.store.CancelScheduledFrom(ctx, u.ID, job.day)
if err != nil {
return err
}
if cancelled > 0 {
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true,
map[string]any{"reason": "jornada encerrada", "count": cancelled})
}
case wd.Count() == 3:
cancelled, err := s.store.CancelScheduled(ctx, u.ID, job.day, domain.NotificationEntrada2)
if err != nil {
return err
}
if cancelled {
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true,
map[string]any{"type": "entrada2", "reason": "retorno do almoço detectado"})
}
predicted, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes)
if err != nil {
if previous.LastPointCount != 3 {
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidWorkday, false, map[string]any{"error": err.Error()})
}
break
}
n, err := s.ensureReminder(ctx, job, domain.NotificationSaida2, predicted, schedule.Saida2LeadMinutes)
if err != nil {
return err
}
if n.Status != domain.NotificationCancelled {
saida2At = &n.ScheduledAt
}
case wd.Count() == 2:
predicted, err := rules.Entrada2Time(wd, schedule.LunchMinutes)
if err != nil {
return err
}
if _, err := s.ensureReminder(ctx, job, domain.NotificationEntrada2, predicted, schedule.Entrada2LeadMinutes); err != nil {
return err
}
}
plan := rules.PlanNextCheck(rules.PlanInput{
Tick: tick,
Day: job.day,
Location: job.loc,
Window: job.window,
Workday: wd,
Resumed: previous.ResumedBy != nil,
Saida2At: saida2At,
Reconciliation: s.cfg.Jobs.ReconciliationInterval,
Slow: s.cfg.Jobs.SlowCheckInterval,
ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom,
ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo,
})
// Se o dia foi retomado (/ativarponto ou painel) durante a consulta, a
// retomada vence: o próximo tick recalcula com o estado novo.
if current, found, err := s.store.GetUserDay(ctx, u.ID, job.day); err == nil && found &&
previous.ResumedBy == nil && current.ResumedBy != nil {
return nil
}
next := previous
next.Status = plan.Status
next.NextCheckAt = plan.NextCheckAt
next.LastPointCount = wd.Count()
if wd.Count() == 0 {
next.ZeroPointChecks++
}
if err := s.store.SaveUserDay(ctx, next); err != nil {
return err
}
if plan.Status == domain.DayNoPoints && previous.Status != domain.DayNoPoints {
s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNoPoints, true, nil)
}
if plan.Status == domain.DayNotWorking && previous.Status != domain.DayNotWorking {
s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNotWorked, true, nil)
}
return nil
}
// ensureReminder cria o lembrete do dia se ainda não existir (um existente não
// é alterado). Se o horário do aviso já passou da tolerância, ele nasce
// expired e é só auditado (9.3).
func (s *Service) ensureReminder(ctx context.Context, job reconJob, t domain.NotificationType, predicted time.Time, lead int) (domain.Notification, error) {
u := job.ru.User
now := s.now()
n := domain.Notification{
UserID: u.ID,
ReferenceDate: job.day,
Type: t,
ScheduledAt: rules.ReminderAt(predicted, lead),
PredictedAt: predicted,
Status: domain.NotificationScheduled,
}
delay := now.Sub(n.ScheduledAt)
if delay > s.cfg.Jobs.LateTolerance {
n.Status = domain.NotificationExpired
n.ErrorMessage = ptr("detectado com atraso acima da tolerância")
}
saved, inserted, err := s.store.InsertNotification(ctx, n)
if err != nil || !inserted {
return saved, err
}
details := map[string]any{
"type": string(t),
"scheduled_at": saved.ScheduledAt,
"predicted_at": saved.PredictedAt,
}
if saved.Status == domain.NotificationExpired {
details["delay_minutes"] = int(delay.Minutes())
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationExpired, false, details)
} else {
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationScheduled, true, details)
}
return saved, nil
}
// CheckScheduleIntervals avisa, na subida do worker, sobre jornadas que ficaram
// fora da regra 9.8 após mudança do intervalo de reconciliação.
func (s *Service) CheckScheduleIntervals(ctx context.Context) {
items, err := s.store.ListActiveSchedules(ctx)
if err != nil {
s.log.Warn("falha ao verificar jornadas", "error", err)
return
}
var names []string
for _, item := range items {
if !rules.ScheduleFitsInterval(item.Schedule, s.cfg.Jobs.ReconciliationInterval) {
names = append(names, item.User.DisplayName)
s.audit(ctx, WorkerActor, &item.User.ID, domain.EventScheduleWindowViolation, false, nil)
}
}
if len(names) > 0 {
s.notifyAdmins(ctx, msgScheduleViolations(names, s.cfg.Jobs.ReconciliationInterval))
}
}

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

View File

@@ -0,0 +1,165 @@
package app
import (
"context"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
const (
keyWindowStart = "query_window_start"
keyWindowEnd = "query_window_end"
keyCheckDeadline = "day_check_deadline"
keyRecheckUntil = "no_points_recheck_until"
)
func windowToSettings(w domain.QueryWindow) map[string]string {
return map[string]string{
keyWindowStart: w.Start.String(),
keyWindowEnd: w.End.String(),
keyCheckDeadline: w.DayCheckDeadline.String(),
keyRecheckUntil: w.NoPointsRecheckUntil.String(),
}
}
// SeedSettings grava os padrões da janela na primeira subida (9.13).
func (s *Service) SeedSettings(ctx context.Context) error {
return s.store.SeedSettings(ctx, windowToSettings(s.cfg.DefaultWindow))
}
// GlobalWindow lê a janela global do banco; valores ausentes ou inválidos usam o padrão.
func (s *Service) GlobalWindow(ctx context.Context) (domain.QueryWindow, error) {
values, err := s.store.GetSettings(ctx)
if err != nil {
return s.cfg.DefaultWindow, err
}
w := s.cfg.DefaultWindow
read := func(key string, target *domain.ClockTime) {
if v, ok := values[key]; ok {
if c, err := domain.ParseClock(v); err == nil {
*target = c
}
}
}
read(keyWindowStart, &w.Start)
read(keyWindowEnd, &w.End)
read(keyCheckDeadline, &w.DayCheckDeadline)
read(keyRecheckUntil, &w.NoPointsRecheckUntil)
return w, nil
}
// UpdateGlobalWindow valida e grava a janela global; vale a partir do próximo tick.
func (s *Service) UpdateGlobalWindow(ctx context.Context, actor Actor, w domain.QueryWindow) error {
if errs := rules.ValidateWindow(w, s.cfg.Jobs.ReconciliationInterval, ""); len(errs) > 0 {
return errs
}
previous, err := s.GlobalWindow(ctx)
if err != nil {
return err
}
if err := s.store.SaveSettings(ctx, windowToSettings(w), actor.AccountID); err != nil {
return err
}
if err := s.store.ResetNextChecks(ctx, s.TodayDate()); err != nil {
s.log.Warn("falha ao recalcular as consultas do dia", "error", err)
}
s.audit(ctx, actor, nil, domain.EventSettingsChanged, true, map[string]any{
"before": windowToSettings(previous),
"after": windowToSettings(w),
})
return nil
}
// WindowPreview é o efeito estimado de uma nova janela global (9.13).
type WindowPreview struct {
Errors domain.ValidationErrors `json:"errors"`
MaxQueriesPerUser int `json:"max_queries_per_user"`
TypicalQueriesPerDay int `json:"typical_queries_per_user"`
DayOffQueries int `json:"day_off_queries_per_user"`
AffectedUsers int `json:"affected_users"`
AffectedDays int `json:"affected_days"`
CustomWindowUsers int `json:"custom_window_users"`
}
func (s *Service) PreviewWindow(ctx context.Context, w domain.QueryWindow) (WindowPreview, error) {
recon := s.cfg.Jobs.ReconciliationInterval
p := WindowPreview{Errors: rules.ValidateWindow(w, recon, "")}
if len(p.Errors) > 0 {
return p, nil
}
p.MaxQueriesPerUser = int(time.Duration(w.End-w.Start)*time.Minute/recon) + 1
p.TypicalQueriesPerDay = s.simulateDay(w, []string{"08:00", "12:00", "13:00", "17:00"})
p.DayOffQueries = s.simulateDay(w, nil)
since := domain.LocalDay(s.now().AddDate(0, 0, -30), s.location)
samples, err := s.store.Entrada2Samples(ctx, since)
if err != nil {
return p, err
}
users := map[int64]bool{}
for _, x := range samples {
if x.CustomWindow {
continue
}
loc := domain.LoadLocation(x.Timezone)
if x.ScheduledAt.Before(w.Start.On(x.Day, loc)) {
users[x.UserID] = true
p.AffectedDays++
}
}
p.AffectedUsers = len(users)
if p.CustomWindowUsers, err = s.store.CountCustomWindows(ctx); err != nil {
return p, err
}
return p, nil
}
// simulateDay conta quantas consultas a frequência adaptativa faria num dia
// em que os pontos são batidos nos horários informados (jornada padrão).
func (s *Service) simulateDay(w domain.QueryWindow, clocks []string) int {
const day = "2026-01-07" // uma quarta-feira qualquer; só os horários importam
loc := s.location
schedule := domain.DefaultSchedule(0)
recon := s.cfg.Jobs.ReconciliationInterval
var punches []domain.Point
for _, c := range clocks {
punches = append(punches, domain.Point{OccurredAt: domain.MustParseClock(c).On(day, loc)})
}
queries := 0
next := w.Start.On(day, loc)
end := w.End.On(day, loc)
for tick := next; !tick.After(end); tick = tick.Add(recon) {
if tick.Before(next) {
continue
}
queries++
var visible []domain.Point
for _, p := range punches {
if !p.OccurredAt.After(tick) {
visible = append(visible, p)
}
}
wd := rules.CurrentWorkday(visible, day, loc)
var saida2At *time.Time
if wd.Count() == 3 {
if t, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes); err == nil {
r := rules.ReminderAt(t, schedule.Saida2LeadMinutes)
saida2At = &r
}
}
plan := rules.PlanNextCheck(rules.PlanInput{
Tick: tick, Day: day, Location: loc, Window: w, Workday: wd, Saida2At: saida2At,
Reconciliation: recon, Slow: s.cfg.Jobs.SlowCheckInterval,
ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom, ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo,
})
if plan.NextCheckAt == nil {
break
}
next = *plan.NextCheckAt
}
return queries
}

View File

@@ -0,0 +1,214 @@
package app
import (
"context"
"errors"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
// TodayView é o resultado de uma consulta ao vivo dos pontos do dia
// (/entrada2, /saida2, botão "testar API" e tela do usuário).
type TodayView struct {
User domain.User
Day string
Points []time.Time
Invalid bool
Overtime bool
Entrada2At *time.Time
Saida2At *time.Time
Saida2Error string
LunchMinutes int
DailyWorkMinutes int
DayState domain.UserDay
Window domain.QueryWindow
CustomWindow bool
Resumed bool // a consulta encontrou pontos e retomou um dia encerrado sem pontos
}
// Today consulta a PontoMais agora. source indica quem pediu, para retomar
// automaticamente um dia "sem pontos" quando a consulta manual encontra pontos (9.12).
func (s *Service) Today(ctx context.Context, actor Actor, userID int64, source *domain.ResumeSource) (TodayView, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return TodayView{}, err
}
cred, err := s.requireValidatedCredential(ctx, userID)
if err != nil {
return TodayView{}, err
}
schedule, err := s.store.GetSchedule(ctx, userID)
if err != nil {
return TodayView{}, err
}
global, err := s.GlobalWindow(ctx)
if err != nil {
return TodayView{}, err
}
loc := u.Location()
points, err := s.points.History(ctx, cred, loc)
if err != nil {
return TodayView{}, err
}
now := s.now()
view := TodayView{
User: u,
Day: domain.LocalDay(now, loc),
LunchMinutes: schedule.LunchMinutes,
DailyWorkMinutes: schedule.DailyWorkMinutes,
}
view.Window, view.CustomWindow = rules.EffectiveWindow(global, schedule)
w := rules.CurrentWorkday(points, view.Day, loc)
view.Invalid, view.Overtime = w.Invalid, w.Overtime
for _, p := range w.Points {
view.Points = append(view.Points, p.OccurredAt)
}
if !w.Invalid && !w.Overtime {
if t, err := rules.Entrada2Time(w, schedule.LunchMinutes); err == nil {
view.Entrada2At = &t
}
if w.Count() >= 3 {
if t, err := rules.Saida2Time(w, schedule.DailyWorkMinutes); err == nil {
view.Saida2At = &t
} else {
view.Saida2Error = err.Error()
}
}
}
if view.DayState, _, err = s.store.GetUserDay(ctx, userID, view.Day); err != nil {
return view, err
}
if source != nil && w.Count() > 0 && view.DayState.IsClosedWithoutPoints() {
if d, err := s.resumeDayIfClosed(ctx, actor, u, *source); err == nil {
view.DayState, view.Resumed = d, true
}
}
return view, nil
}
// TestCredentials consulta a PontoMais com a credencial atual (botão do painel).
func (s *Service) TestCredentials(ctx context.Context, actor Actor, userID int64) (TodayView, error) {
view, err := s.Today(ctx, actor, userID, nil)
if err != nil {
if errors.Is(err, domain.ErrCredentialsRequired) {
// Credencial gravada mas nunca validada também pode ser testada.
if cred, cerr := s.store.GetCredential(ctx, userID); cerr == nil {
return s.testUnvalidated(ctx, actor, userID, cred)
}
}
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
return view, err
}
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
return view, err
}
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, true, map[string]any{"points": len(view.Points)})
return view, nil
}
func (s *Service) testUnvalidated(ctx context.Context, actor Actor, userID int64, cred domain.Credential) (TodayView, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return TodayView{}, err
}
if _, err := s.points.History(ctx, cred, u.Location()); err != nil {
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
return TodayView{}, err
}
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
return TodayView{}, err
}
return s.Today(ctx, actor, userID, nil)
}
// ResumeDay retoma um dia encerrado sem pontos (botão do painel, 9.12).
func (s *Service) ResumeDay(ctx context.Context, actor Actor, userID int64) (domain.UserDay, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return domain.UserDay{}, err
}
d, err := s.resumeDayIfClosed(ctx, actor, u, domain.ResumeWeb)
if errors.Is(err, errNothingToResume) {
return d, domain.InvalidState("o dia não está encerrado por falta de pontos")
}
return d, err
}
var errNothingToResume = errors.New("nada a retomar")
func (s *Service) resumeDayIfClosed(ctx context.Context, actor Actor, u domain.User, source domain.ResumeSource) (domain.UserDay, error) {
loc := u.Location()
now := s.now()
day := domain.LocalDay(now, loc)
d, found, err := s.store.GetUserDay(ctx, u.ID, day)
if err != nil {
return d, err
}
if !found || !d.IsClosedWithoutPoints() {
return d, errNothingToResume
}
window, err := s.windowFor(ctx, u.ID)
if err != nil {
return d, err
}
d.Status = domain.DayWorking
d.ResumedBy = &source
d.NextCheckAt = rules.ResumeNextCheck(now, window, day, loc, s.cfg.Jobs.ReconciliationInterval)
if err := s.store.SaveUserDay(ctx, d); err != nil {
return d, err
}
s.audit(ctx, actor, &u.ID, domain.EventDayResumed, true, map[string]any{"source": string(source)})
return d, nil
}
// windowFor devolve a janela efetiva de um usuário.
func (s *Service) windowFor(ctx context.Context, userID int64) (domain.QueryWindow, error) {
global, err := s.GlobalWindow(ctx)
if err != nil {
return global, err
}
schedule, err := s.store.GetSchedule(ctx, userID)
if err != nil {
return global, err
}
w, _ := rules.EffectiveWindow(global, schedule)
return w, nil
}
// StatusInfo reúne o que o /status mostra.
type StatusInfo struct {
User domain.User
HasCredential bool
Validated bool
Failures int
Today domain.UserDay
Window domain.QueryWindow
CustomWindow bool
BusinessDay bool
}
func (s *Service) Status(ctx context.Context, userID int64) (StatusInfo, error) {
detail, err := s.UserDetail(ctx, userID)
if err != nil {
return StatusInfo{}, err
}
info := StatusInfo{
User: detail.User,
Today: detail.Today,
Window: detail.Window,
CustomWindow: detail.CustomWindow,
}
if detail.Credential != nil {
info.HasCredential = true
info.Validated = detail.Credential.IsValidated()
info.Failures = detail.Credential.ConsecutiveFailures
}
info.BusinessDay, _ = s.IsBusinessDay(ctx, s.now(), detail.User.Location())
return info, nil
}

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
}

View File

@@ -0,0 +1,296 @@
package app
import (
"context"
"errors"
"net/netip"
"regexp"
"strings"
"time"
"botpontos/internal/adapters/crypto"
"botpontos/internal/adapters/postgres"
"botpontos/internal/domain"
)
type LoginResult struct {
Token string
CSRFToken string
ExpiresAt time.Time
Account domain.WebAccount
}
// Login valida usuário e senha (Argon2id), aplica o bloqueio por tentativas e cria a sessão.
func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (LoginResult, error) {
now := s.now()
username = strings.TrimSpace(username)
account, err := s.store.GetWebAccountByUsername(ctx, username)
if errors.Is(err, domain.ErrNotFound) {
crypto.DummyVerify(password)
s.audit(ctx, Actor{Source: domain.SourceWeb}, nil, domain.EventWebLoginFailed, false,
map[string]any{"username": username, "reason": "usuário inexistente", "ip": ip})
return LoginResult{}, domain.ErrInvalidLogin
}
if err != nil {
return LoginResult{}, err
}
actor := WebActor(account.ID)
if account.IsDisabled() {
crypto.DummyVerify(password)
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta desativada", "ip": ip})
return LoginResult{}, domain.ErrInvalidLogin
}
if account.IsLocked(now) {
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta bloqueada", "ip": ip})
return LoginResult{}, domain.ErrAccountLocked
}
if !crypto.VerifyPassword(account.PasswordHash, password) {
locked, err := s.store.RecordLoginFailure(ctx, account.ID, s.cfg.Web.LoginMaxFailures, now.Add(s.cfg.Web.LoginLock))
if err != nil {
return LoginResult{}, err
}
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "senha incorreta", "locked": locked, "ip": ip})
if locked {
return LoginResult{}, domain.ErrAccountLocked
}
return LoginResult{}, domain.ErrInvalidLogin
}
if err := s.store.RecordLoginSuccess(ctx, account.ID, now); err != nil {
return LoginResult{}, err
}
token, tokenHash, err := crypto.NewToken()
if err != nil {
return LoginResult{}, err
}
csrf, csrfHash, err := crypto.NewToken()
if err != nil {
return LoginResult{}, err
}
expires := now.Add(s.cfg.Web.SessionDuration)
if err := s.store.CreateSession(ctx, postgres.NewSession{
AccountID: account.ID,
TokenHash: tokenHash,
CSRFTokenHash: csrfHash,
ExpiresAt: expires,
UserAgent: limitPtr(userAgent, 300),
IP: ipPtr(ip),
}); err != nil {
return LoginResult{}, err
}
s.audit(ctx, actor, nil, domain.EventWebLogin, true, map[string]any{"ip": ip})
account.PasswordHash = ""
return LoginResult{Token: token, CSRFToken: csrf, ExpiresAt: expires, Account: account}, nil
}
// Authenticate valida o token de sessão: revogação, expiração absoluta e inatividade.
func (s *Service) Authenticate(ctx context.Context, token string) (domain.WebSession, error) {
if !crypto.ValidTokenFormat(token) {
return domain.WebSession{}, domain.ErrUnauthenticated
}
session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token))
if errors.Is(err, domain.ErrNotFound) {
return session, domain.ErrUnauthenticated
}
if err != nil {
return session, err
}
now := s.now()
switch {
case session.RevokedAt != nil, now.After(session.ExpiresAt), session.Account.IsDisabled():
return session, domain.ErrUnauthenticated
case now.Sub(session.LastSeenAt) > s.cfg.Web.SessionIdle:
_ = s.store.RevokeSession(ctx, session.ID)
return session, domain.ErrUnauthenticated
}
if now.Sub(session.LastSeenAt) > time.Minute {
if err := s.store.TouchSession(ctx, session.ID, now); err != nil {
s.log.Warn("falha ao atualizar sessão", "error", err)
}
}
session.Account.PasswordHash = ""
return session, nil
}
func (s *Service) CheckCSRF(session domain.WebSession, token string) bool {
return token != "" && crypto.TokenMatches(token, session.CSRFTokenHash)
}
func (s *Service) Logout(ctx context.Context, session domain.WebSession) error {
if err := s.store.RevokeSession(ctx, session.ID); err != nil {
return err
}
s.audit(ctx, WebActor(session.AccountID), nil, domain.EventWebLogout, true, nil)
return nil
}
var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{3,50}$`)
type AccountInput struct {
Username string
Password string
Role domain.Role
UserID *int64
}
func (s *Service) CreateAccount(ctx context.Context, actor Actor, in AccountInput) (domain.WebAccount, error) {
var errs domain.ValidationErrors
in.Username = strings.TrimSpace(in.Username)
if !usernamePattern.MatchString(in.Username) {
errs.Add("username", "use de 3 a 50 caracteres: letras, números, ponto, hífen ou sublinhado")
}
if !in.Role.Valid() {
errs.Add("role", "use owner, admin ou viewer")
}
if err := crypto.ValidatePassword(in.Password); err != nil {
errs.Add("password", err.Error())
}
if in.UserID != nil {
if err := s.checkAccountUser(ctx, 0, *in.UserID); err != nil {
errs.Add("user_id", err.Error())
}
}
if len(errs) > 0 {
return domain.WebAccount{}, errs
}
hash, err := crypto.HashPassword(in.Password)
if err != nil {
return domain.WebAccount{}, err
}
account, err := s.store.CreateWebAccount(ctx, in.Username, hash, in.Role, in.UserID)
if errors.Is(err, domain.ErrConflict) {
return account, domain.ValidationErrors{{Field: "username", Message: "já existe uma conta com esse nome"}}
}
if err != nil {
return account, err
}
s.audit(ctx, actor, in.UserID, domain.EventWebAccountCreated, true,
map[string]any{"account_id": account.ID, "username": account.Username, "role": string(account.Role)})
account.PasswordHash = ""
return account, nil
}
// checkAccountUser confere que o usuário do Telegram existe e não está ligado a
// outra conta do painel (accountID é a conta que está sendo alterada, 0 se nova).
func (s *Service) checkAccountUser(ctx context.Context, accountID, userID int64) error {
if _, err := s.store.GetUser(ctx, userID); err != nil {
return errors.New("usuário do Telegram não encontrado")
}
accounts, err := s.store.ListWebAccounts(ctx)
if err != nil {
return err
}
for _, a := range accounts {
if a.ID != accountID && a.UserID != nil && *a.UserID == userID {
return errors.New("esse usuário já está ligado à conta " + a.Username)
}
}
return nil
}
// AccountUserLink pede para trocar o vínculo da conta; UserID nil remove o vínculo.
type AccountUserLink struct {
UserID *int64
}
// UpdateAccount altera papel, situação e/ou vínculo com o usuário do Telegram,
// mantendo sempre um owner ativo (9.6).
func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id int64, role *domain.Role, disabled *bool, link *AccountUserLink) error {
if role != nil && !role.Valid() {
return domain.ValidationErrors{{Field: "role", Message: "use owner, admin ou viewer"}}
}
if link != nil && link.UserID != nil {
if err := s.checkAccountUser(ctx, id, *link.UserID); err != nil {
return domain.ValidationErrors{{Field: "user_id", Message: err.Error()}}
}
}
account, err := s.store.GetWebAccount(ctx, id)
if err != nil {
return err
}
losesOwner := account.Role == domain.RoleOwner && !account.IsDisabled() &&
((role != nil && *role != domain.RoleOwner) || (disabled != nil && *disabled))
if losesOwner {
owners, err := s.store.CountActiveOwners(ctx)
if err != nil {
return err
}
if owners <= 1 {
return domain.ErrLastOwner
}
}
var userID *int64
if link != nil {
userID = link.UserID
}
if err := s.store.UpdateWebAccount(ctx, id, role, disabled, link != nil, userID); err != nil {
return err
}
if disabled != nil && *disabled {
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
return err
}
}
details := map[string]any{"account_id": id}
if role != nil {
details["role"] = string(*role)
}
if disabled != nil {
details["disabled"] = *disabled
}
if link != nil {
details["user_id"] = link.UserID
}
s.audit(ctx, actor, userID, domain.EventWebAccountChanged, true, details)
return nil
}
// ResetPassword troca a senha e encerra todas as sessões da conta.
func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error {
if err := crypto.ValidatePassword(password); err != nil {
return domain.ValidationErrors{{Field: "password", Message: err.Error()}}
}
if _, err := s.store.GetWebAccount(ctx, id); err != nil {
return err
}
hash, err := crypto.HashPassword(password)
if err != nil {
return err
}
if err := s.store.SetWebAccountPassword(ctx, id, hash); err != nil {
return err
}
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventWebPasswordReset, true, map[string]any{"account_id": id})
return nil
}
func (s *Service) ResetPasswordByUsername(ctx context.Context, actor Actor, username, password string) error {
account, err := s.store.GetWebAccountByUsername(ctx, username)
if err != nil {
return err
}
return s.ResetPassword(ctx, actor, account.ID, password)
}
func limitPtr(v string, max int) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
v = truncate(v, max)
return &v
}
// ipPtr só aceita endereços válidos (a coluna é inet).
func ipPtr(ip string) *string {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil
}
v := addr.String()
return &v
}