Projeto bot pontos
This commit is contained in:
84
backend/internal/domain/audit.go
Normal file
84
backend/internal/domain/audit.go
Normal 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
|
||||
}
|
||||
54
backend/internal/domain/clock.go
Normal file
54
backend/internal/domain/clock.go
Normal 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())
|
||||
}
|
||||
19
backend/internal/domain/credential.go
Normal file
19
backend/internal/domain/credential.go
Normal 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 }
|
||||
44
backend/internal/domain/day.go
Normal file
44
backend/internal/domain/day.go
Normal 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
|
||||
}
|
||||
57
backend/internal/domain/errors.go
Normal file
57
backend/internal/domain/errors.go
Normal 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} }
|
||||
12
backend/internal/domain/holiday.go
Normal file
12
backend/internal/domain/holiday.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package domain
|
||||
|
||||
const (
|
||||
HolidaySourceAPI = "feriadosapi"
|
||||
HolidaySourceManual = "manual"
|
||||
)
|
||||
|
||||
type Holiday struct {
|
||||
Day string // AAAA-MM-DD
|
||||
Name string
|
||||
Source string
|
||||
}
|
||||
51
backend/internal/domain/notification.go
Normal file
51
backend/internal/domain/notification.go
Normal 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
|
||||
}
|
||||
26
backend/internal/domain/point.go
Normal file
26
backend/internal/domain/point.go
Normal 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 }
|
||||
46
backend/internal/domain/schedule.go
Normal file
46
backend/internal/domain/schedule.go
Normal 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
|
||||
}
|
||||
72
backend/internal/domain/user.go
Normal file
72
backend/internal/domain/user.go
Normal 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"
|
||||
58
backend/internal/domain/web.go
Normal file
58
backend/internal/domain/web.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user