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,84 @@
package domain
import "time"
type AuditSource string
const (
SourceTelegram AuditSource = "telegram"
SourceWeb AuditSource = "web"
SourceWorker AuditSource = "worker"
SourceCLI AuditSource = "cli"
)
// Tipos de evento gravados em audit_logs.
const (
EventUserRegistered = "user_registered"
EventUserActivated = "user_activated"
EventUserDisabled = "user_disabled"
EventBotAdminChanged = "bot_admin_changed"
EventScheduleChanged = "schedule_changed"
EventUserWindowChanged = "user_window_changed"
EventCredentialsChanged = "credentials_changed"
EventCredentialsTested = "credentials_tested"
EventNotificationsEnabled = "notifications_enabled"
EventNotificationsDisabled = "notifications_disabled"
EventNotificationsPausedAPI = "notifications_paused_api_failure"
EventPointAPIFailure = "point_api_failure"
EventPointAPIOutageStarted = "point_api_outage_started"
EventPointAPIOutageEnded = "point_api_outage_ended"
EventInvalidPointHistory = "invalid_point_history"
EventInvalidWorkday = "invalid_workday_calculation"
EventNotificationScheduled = "notification_scheduled"
EventNotificationSent = "notification_sent"
EventNotificationFailed = "notification_failed"
EventNotificationExpired = "notification_expired"
EventNotificationCancelled = "notification_cancelled"
EventNotificationResent = "notification_resent"
EventInterruptedSending = "notification_interrupted"
EventDayNoPoints = "day_no_points"
EventDayNotWorked = "day_not_worked"
EventDayResumed = "day_resumed"
EventHolidaySyncFailed = "holiday_sync_failed"
EventHolidaySynced = "holiday_synced"
EventHolidayAdded = "holiday_added"
EventHolidayRemoved = "holiday_removed"
EventSettingsChanged = "settings_changed"
EventWebLogin = "web_login"
EventWebLoginFailed = "web_login_failed"
EventWebLogout = "web_logout"
EventWebAccountCreated = "web_account_created"
EventWebAccountChanged = "web_account_changed"
EventWebPasswordReset = "web_password_reset"
EventRetentionApplied = "retention_applied"
EventEncryptionKeyRotated = "encryption_key_rotated"
EventTelegramFailure = "telegram_failure"
EventScheduleWindowViolation = "schedule_window_violation"
)
// AuditEntry é um evento a ser gravado. Details nunca deve conter segredos.
type AuditEntry struct {
Source AuditSource
ActorUserID *int64
ActorAccount *int64
TargetUserID *int64
EventType string
Success bool
Details map[string]any
}
// AuditLog é um evento lido do banco, com nomes para exibição.
type AuditLog struct {
ID int64
OccurredAt time.Time
Source AuditSource
ActorUserID *int64
ActorUserName *string
ActorAccount *int64
ActorUsername *string
TargetUserID *int64
TargetUserName *string
EventType string
Success bool
Details map[string]any
}

View File

@@ -0,0 +1,54 @@
package domain
import (
"fmt"
"time"
)
// ClockTime é um horário do dia (HH:MM), guardado em minutos desde 00:00.
type ClockTime int
func ParseClock(value string) (ClockTime, error) {
if len(value) != 5 || value[2] != ':' {
return 0, fmt.Errorf("horário inválido %q (use HH:MM)", value)
}
digits := [4]byte{value[0], value[1], value[3], value[4]}
for _, d := range digits {
if d < '0' || d > '9' {
return 0, fmt.Errorf("horário inválido %q (use HH:MM)", value)
}
}
hour := int(digits[0]-'0')*10 + int(digits[1]-'0')
minute := int(digits[2]-'0')*10 + int(digits[3]-'0')
if hour > 23 || minute > 59 {
return 0, fmt.Errorf("horário inválido %q", value)
}
return ClockTime(hour*60 + minute), nil
}
func MustParseClock(value string) ClockTime {
c, err := ParseClock(value)
if err != nil {
panic(err)
}
return c
}
func (c ClockTime) String() string {
return fmt.Sprintf("%02d:%02d", int(c)/60, int(c)%60)
}
// On devolve o instante desse horário na data (AAAA-MM-DD) e fuso informados.
func (c ClockTime) On(day string, loc *time.Location) time.Time {
date, err := time.ParseInLocation(DateLayout, day, loc)
if err != nil {
return time.Time{}
}
return time.Date(date.Year(), date.Month(), date.Day(), int(c)/60, int(c)%60, 0, 0, loc)
}
// ClockOf devolve o horário do dia de t no fuso informado.
func ClockOf(t time.Time, loc *time.Location) ClockTime {
local := t.In(loc)
return ClockTime(local.Hour()*60 + local.Minute())
}

View File

@@ -0,0 +1,19 @@
package domain
import "time"
// Credential é a credencial PontoMais de um usuário. A senha fica cifrada.
type Credential struct {
UserID int64
APIEmail string
EncryptedPassword []byte
KeyVersion int16
LastValidatedAt *time.Time
LastError *string
ConsecutiveFailures int
LastFailureAt *time.Time
UpdatedAt time.Time
}
// IsValidated indica que a credencial já autenticou com sucesso na PontoMais.
func (c Credential) IsValidated() bool { return c.LastValidatedAt != nil }

View File

@@ -0,0 +1,44 @@
package domain
import "time"
// DayStatus é a situação de um usuário num dia (ARQUITETURA.md 9.12).
type DayStatus string
const (
DayAwaiting DayStatus = "awaiting"
DayNoPoints DayStatus = "no_points"
DayWorking DayStatus = "working"
DayNotWorking DayStatus = "not_working"
DayFinished DayStatus = "finished"
)
type ResumeSource string
const (
ResumeAtivarPonto ResumeSource = "ativarponto"
ResumeEntrada2 ResumeSource = "entrada2"
ResumeSaida2 ResumeSource = "saida2"
ResumeWeb ResumeSource = "web"
)
type UserDay struct {
UserID int64
Day string
Status DayStatus
ZeroPointChecks int
LastPointCount int
NextCheckAt *time.Time
ResumedBy *ResumeSource
UpdatedAt time.Time
}
// NewUserDay é a situação inicial de um dia ainda não consultado.
func NewUserDay(userID int64, day string) UserDay {
return UserDay{UserID: userID, Day: day, Status: DayAwaiting}
}
// IsClosedWithoutPoints indica um dia sem pontos (em reverificação ou encerrado).
func (d UserDay) IsClosedWithoutPoints() bool {
return d.Status == DayNoPoints || d.Status == DayNotWorking
}

View File

@@ -0,0 +1,57 @@
package domain
import (
"errors"
"strings"
)
var (
ErrNotFound = errors.New("registro não encontrado")
ErrConflict = errors.New("registro já existe")
ErrCredentialsRequired = errors.New("credencial da PontoMais não configurada ou não validada")
ErrUserNotActive = errors.New("usuário não está ativo")
ErrInvalidState = errors.New("operação não permitida no estado atual")
ErrUnauthenticated = errors.New("sessão inválida ou expirada")
ErrForbidden = errors.New("permissão insuficiente")
ErrInvalidLogin = errors.New("usuário ou senha inválidos")
ErrAccountLocked = errors.New("conta bloqueada temporariamente por tentativas de login")
ErrLastOwner = errors.New("é preciso manter pelo menos um owner ativo")
ErrNotConfigured = errors.New("integração não configurada")
)
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ValidationErrors agrupa erros de validação de campos (HTTP 422).
type ValidationErrors []ValidationError
func (v ValidationErrors) Error() string {
parts := make([]string, 0, len(v))
for _, e := range v {
parts = append(parts, e.Field+": "+e.Message)
}
return strings.Join(parts, "; ")
}
func (v *ValidationErrors) Add(field, message string) {
*v = append(*v, ValidationError{Field: field, Message: message})
}
// OrNil devolve nil quando não há erros, para uso em `return errs.OrNil()`.
func (v ValidationErrors) OrNil() error {
if len(v) == 0 {
return nil
}
return v
}
// stateError é um ErrInvalidState com mensagem específica para o usuário.
type stateError struct{ msg string }
func (e stateError) Error() string { return e.msg }
func (e stateError) Is(target error) bool { return target == ErrInvalidState }
// InvalidState cria um erro que satisfaz errors.Is(err, ErrInvalidState).
func InvalidState(msg string) error { return stateError{msg: msg} }

View File

@@ -0,0 +1,12 @@
package domain
const (
HolidaySourceAPI = "feriadosapi"
HolidaySourceManual = "manual"
)
type Holiday struct {
Day string // AAAA-MM-DD
Name string
Source string
}

View File

@@ -0,0 +1,51 @@
package domain
import "time"
type NotificationType string
const (
NotificationEntrada2 NotificationType = "entrada2"
NotificationSaida2 NotificationType = "saida2"
)
type NotificationStatus string
const (
NotificationScheduled NotificationStatus = "scheduled"
NotificationSending NotificationStatus = "sending"
NotificationSent NotificationStatus = "sent"
NotificationFailed NotificationStatus = "failed"
NotificationCancelled NotificationStatus = "cancelled"
NotificationExpired NotificationStatus = "expired"
)
type Notification struct {
ID int64
UserID int64
ReferenceDate string
Type NotificationType
ScheduledAt time.Time // quando o aviso deve sair
PredictedAt time.Time // horário previsto do ponto (retorno ou saída)
Status NotificationStatus
Attempts int
LockedAt *time.Time
SentAt *time.Time
TelegramMessageID *int64
ErrorMessage *string
CreatedAt time.Time
UpdatedAt time.Time
}
func ValidNotificationType(t string) bool {
return t == string(NotificationEntrada2) || t == string(NotificationSaida2)
}
func ValidNotificationStatus(s string) bool {
switch NotificationStatus(s) {
case NotificationScheduled, NotificationSending, NotificationSent,
NotificationFailed, NotificationCancelled, NotificationExpired:
return true
}
return false
}

View File

@@ -0,0 +1,26 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidPointHistory = errors.New("histórico de pontos inválido")
type Point struct {
OccurredAt time.Time
}
// Workday são os pontos de um dia, em ordem.
type Workday struct {
Points []Point
Invalid bool // pontos repetidos ou fora de ordem
Overtime bool // mais de quatro pontos (hora extra, fora do escopo)
}
func (w Workday) Count() int { return len(w.Points) }
func (w Workday) Entry1() time.Time { return w.Points[0].OccurredAt }
func (w Workday) Exit1() time.Time { return w.Points[1].OccurredAt }
func (w Workday) Entry2() time.Time { return w.Points[2].OccurredAt }
func (w Workday) Exit2() time.Time { return w.Points[3].OccurredAt }

View File

@@ -0,0 +1,46 @@
package domain
// QueryWindow são os horários da janela de consulta à PontoMais (ARQUITETURA.md 9.12).
type QueryWindow struct {
Start ClockTime
End ClockTime
DayCheckDeadline ClockTime
NoPointsRecheckUntil ClockTime
}
func DefaultQueryWindow() QueryWindow {
return QueryWindow{
Start: MustParseClock("11:30"),
End: MustParseClock("19:00"),
DayCheckDeadline: MustParseClock("11:45"),
NoPointsRecheckUntil: MustParseClock("15:00"),
}
}
// WorkSchedule são as regras de jornada do usuário e a exceção opcional de janela (9.14).
type WorkSchedule struct {
UserID int64
LunchMinutes int
DailyWorkMinutes int
Entrada2LeadMinutes int
Saida2LeadMinutes int
Window *QueryWindow // nil = usa a janela global
}
func DefaultSchedule(userID int64) WorkSchedule {
return WorkSchedule{
UserID: userID,
LunchMinutes: 60,
DailyWorkMinutes: 480,
Entrada2LeadMinutes: 5,
Saida2LeadMinutes: 5,
}
}
// LeadMinutes devolve a antecedência configurada para o tipo de lembrete.
func (s WorkSchedule) LeadMinutes(t NotificationType) int {
if t == NotificationSaida2 {
return s.Saida2LeadMinutes
}
return s.Entrada2LeadMinutes
}

View File

@@ -0,0 +1,72 @@
// Package domain contém as entidades e estados centrais. Não depende de
// nenhum outro pacote do projeto.
package domain
import "time"
type UserStatus string
const (
UserPending UserStatus = "pending"
UserActive UserStatus = "active"
UserDisabled UserStatus = "disabled"
)
// PauseReason explica por que os lembretes de um usuário estão desligados (/status).
type PauseReason string
const (
PauseManual PauseReason = "manual"
PauseAPIFailures PauseReason = "api_failures"
PauseAdmin PauseReason = "admin"
PauseUserDisabled PauseReason = "user_disabled"
)
const DefaultTimezone = "America/Sao_Paulo"
type User struct {
ID int64
TelegramChatID int64
DisplayName string
Status UserStatus
IsBotAdmin bool
NotificationsEnabled bool
PausedReason *PauseReason
Timezone string
CreatedAt time.Time
UpdatedAt time.Time
}
func (u User) IsActive() bool { return u.Status == UserActive }
// IsActiveBotAdmin indica quem pode usar os comandos administrativos do Telegram.
func (u User) IsActiveBotAdmin() bool { return u.IsActive() && u.IsBotAdmin }
// Location devolve o fuso do usuário; um valor inválido cai no fuso padrão.
func (u User) Location() *time.Location { return LoadLocation(u.Timezone) }
var fallbackLocation = func() *time.Location {
loc, err := time.LoadLocation(DefaultTimezone)
if err != nil {
return time.UTC
}
return loc
}()
func LoadLocation(name string) *time.Location {
if name == "" {
return fallbackLocation
}
loc, err := time.LoadLocation(name)
if err != nil {
return fallbackLocation
}
return loc
}
// LocalDay devolve a data (AAAA-MM-DD) de t no fuso informado.
func LocalDay(t time.Time, loc *time.Location) string {
return t.In(loc).Format(DateLayout)
}
const DateLayout = "2006-01-02"

View File

@@ -0,0 +1,58 @@
package domain
import "time"
// Role é o papel de uma conta do painel (ARQUITETURA.md 9.6).
type Role string
const (
RoleViewer Role = "viewer"
RoleAdmin Role = "admin"
RoleOwner Role = "owner"
)
func (r Role) rank() int {
switch r {
case RoleOwner:
return 3
case RoleAdmin:
return 2
case RoleViewer:
return 1
}
return 0
}
func (r Role) Valid() bool { return r.rank() > 0 }
// AtLeast indica se o papel tem pelo menos as permissões de min.
func (r Role) AtLeast(min Role) bool { return r.rank() >= min.rank() && r.Valid() }
type WebAccount struct {
ID int64
Username string
PasswordHash string
Role Role
UserID *int64
FailedLoginCount int
LockedUntil *time.Time
DisabledAt *time.Time
LastLoginAt *time.Time
CreatedAt time.Time
}
func (a WebAccount) IsDisabled() bool { return a.DisabledAt != nil }
func (a WebAccount) IsLocked(now time.Time) bool {
return a.LockedUntil != nil && now.Before(*a.LockedUntil)
}
type WebSession struct {
ID int64
AccountID int64
CSRFTokenHash []byte
ExpiresAt time.Time
LastSeenAt time.Time
RevokedAt *time.Time
Account WebAccount
}