Projeto bot pontos
This commit is contained in:
83
backend/internal/adapters/crypto/crypto_test.go
Normal file
83
backend/internal/adapters/crypto/crypto_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
keyA = "chave-de-teste-numero-um-com-32-caracteres!"
|
||||
keyB = "chave-de-teste-numero-dois-com-32-caracteres"
|
||||
)
|
||||
|
||||
func TestKeyringRoundTrip(t *testing.T) {
|
||||
k, err := NewKeyring(keyA, 1, "", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, version, err := k.Encrypt("senha-pontomais")
|
||||
if err != nil || version != 1 {
|
||||
t.Fatalf("Encrypt: version=%d err=%v", version, err)
|
||||
}
|
||||
if strings.Contains(string(data), "senha-pontomais") {
|
||||
t.Fatal("texto cifrado contém a senha em claro")
|
||||
}
|
||||
plain, err := k.Decrypt(data, version)
|
||||
if err != nil || plain != "senha-pontomais" {
|
||||
t.Fatalf("Decrypt = %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRotation(t *testing.T) {
|
||||
old, _ := NewKeyring(keyA, 1, "", 0)
|
||||
data, version, _ := old.Encrypt("segredo")
|
||||
|
||||
rotated, err := NewKeyring(keyB, 2, keyA, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain, err := rotated.Decrypt(data, version); err != nil || plain != "segredo" {
|
||||
t.Fatalf("chave anterior deveria decifrar: %q, %v", plain, err)
|
||||
}
|
||||
if _, v, _ := rotated.Encrypt("novo"); v != 2 {
|
||||
t.Fatalf("novas cifras deveriam usar a versão 2, usaram %d", v)
|
||||
}
|
||||
if _, err := rotated.Decrypt(data, 9); err == nil {
|
||||
t.Fatal("versão desconhecida deveria falhar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRejectsShortKey(t *testing.T) {
|
||||
if _, err := NewKeyring("curta", 1, "", 0); err == nil {
|
||||
t.Fatal("chave curta deveria ser recusada")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
hash, err := HashPassword("senha-forte-do-painel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(hash, "senha-forte-do-painel") {
|
||||
t.Fatal("senha correta deveria conferir")
|
||||
}
|
||||
if VerifyPassword(hash, "senha-errada-do-painel") {
|
||||
t.Fatal("senha errada não deveria conferir")
|
||||
}
|
||||
if _, err := HashPassword("curta"); err == nil {
|
||||
t.Fatal("senha curta deveria ser recusada")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken(t *testing.T) {
|
||||
token, hash, err := NewToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ValidTokenFormat(token) || !TokenMatches(token, hash) {
|
||||
t.Fatal("token gerado deveria ser válido e conferir com o hash")
|
||||
}
|
||||
if TokenMatches(token+"x", hash) {
|
||||
t.Fatal("token alterado não deveria conferir")
|
||||
}
|
||||
}
|
||||
90
backend/internal/adapters/crypto/keyring.go
Normal file
90
backend/internal/adapters/crypto/keyring.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Package crypto cifra as senhas da PontoMais (AES-256-GCM), faz hash das
|
||||
// senhas do painel (Argon2id) e gera tokens de sessão.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const MinKeyLength = 32
|
||||
|
||||
var ErrUnknownKeyVersion = errors.New("versão de chave de criptografia desconhecida")
|
||||
|
||||
// Keyring guarda a chave atual e, opcionalmente, a anterior. Cada senha cifrada
|
||||
// registra a versão usada (point_credentials.key_version), o que permite trocar
|
||||
// a chave sem perder as credenciais já gravadas (comando admin rotate-key).
|
||||
type Keyring struct {
|
||||
current int16
|
||||
keys map[int16][]byte
|
||||
}
|
||||
|
||||
// NewKeyring recebe o segredo atual e sua versão; previous é opcional (vazio = sem chave anterior).
|
||||
func NewKeyring(current string, currentVersion int16, previous string, previousVersion int16) (*Keyring, error) {
|
||||
if len(current) < MinKeyLength {
|
||||
return nil, fmt.Errorf("a chave de criptografia precisa ter pelo menos %d caracteres", MinKeyLength)
|
||||
}
|
||||
k := &Keyring{current: currentVersion, keys: map[int16][]byte{currentVersion: derive(current)}}
|
||||
if previous != "" {
|
||||
if previousVersion == currentVersion {
|
||||
return nil, errors.New("a chave anterior precisa ter uma versão diferente da atual")
|
||||
}
|
||||
if len(previous) < MinKeyLength {
|
||||
return nil, fmt.Errorf("a chave anterior precisa ter pelo menos %d caracteres", MinKeyLength)
|
||||
}
|
||||
k.keys[previousVersion] = derive(previous)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func derive(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func (k *Keyring) CurrentVersion() int16 { return k.current }
|
||||
|
||||
// Encrypt cifra com a chave atual e devolve nonce||ciphertext e a versão usada.
|
||||
func (k *Keyring) Encrypt(plain string) ([]byte, int16, error) {
|
||||
gcm, err := newGCM(k.keys[k.current])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plain), nil), k.current, nil
|
||||
}
|
||||
|
||||
func (k *Keyring) Decrypt(data []byte, version int16) (string, error) {
|
||||
key, ok := k.keys[version]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %d", ErrUnknownKeyVersion, version)
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) < gcm.NonceSize() {
|
||||
return "", errors.New("credencial cifrada inválida")
|
||||
}
|
||||
plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("não foi possível decifrar a credencial (chave incorreta?)")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
107
backend/internal/adapters/crypto/password.go
Normal file
107
backend/internal/adapters/crypto/password.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
MinPasswordLength = 12
|
||||
|
||||
argonMemory = 64 * 1024
|
||||
argonIterations = 3
|
||||
argonParallelism = 2
|
||||
argonSaltLength = 16
|
||||
argonKeyLength = 32
|
||||
)
|
||||
|
||||
// ValidatePassword aplica a política mínima das senhas do painel.
|
||||
func ValidatePassword(password string) error {
|
||||
if utf8.RuneCountInString(password) < MinPasswordLength {
|
||||
return fmt.Errorf("a senha deve ter no mínimo %d caracteres", MinPasswordLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashPassword gera um hash Argon2id no formato PHC ($argon2id$v=19$m=...,t=...,p=...$salt$hash).
|
||||
func HashPassword(password string) (string, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
salt := make([]byte, argonSaltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonIterations, argonMemory, argonParallelism, argonKeyLength)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
argonMemory, argonIterations, argonParallelism,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compara em tempo constante. Parâmetros fora de limites
|
||||
// razoáveis são recusados, para um hash adulterado não travar o servidor.
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var memory, iterations uint32
|
||||
var parallelism uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil {
|
||||
return false
|
||||
}
|
||||
if memory < 8*1024 || memory > 256*1024 || iterations < 1 || iterations > 10 || parallelism < 1 || parallelism > 8 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < 8 {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(want) < 16 || len(want) > 64 {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// DummyVerify gasta o mesmo tempo de uma verificação real; usado quando o
|
||||
// usuário não existe, para o tempo de resposta não revelar isso.
|
||||
func DummyVerify(password string) {
|
||||
salt := sha256.Sum256([]byte("botpontos-dummy-salt"))
|
||||
_ = argon2.IDKey([]byte(password), salt[:argonSaltLength], argonIterations, argonMemory, argonParallelism, argonKeyLength)
|
||||
}
|
||||
|
||||
// NewToken gera um token aleatório (enviado ao navegador) e seu hash SHA-256 (gravado no banco).
|
||||
func NewToken() (token string, hash []byte, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(raw)
|
||||
return token, HashToken(token), nil
|
||||
}
|
||||
|
||||
func HashToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// ValidTokenFormat evita consultar o banco com valores claramente inválidos.
|
||||
func ValidTokenFormat(token string) bool {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
return err == nil && len(raw) == 32
|
||||
}
|
||||
|
||||
// TokenMatches compara um token recebido com o hash gravado, em tempo constante.
|
||||
func TokenMatches(token string, hash []byte) bool {
|
||||
return subtle.ConstantTimeCompare(HashToken(token), hash) == 1
|
||||
}
|
||||
112
backend/internal/adapters/feriados/client.go
Normal file
112
backend/internal/adapters/feriados/client.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package feriados consulta a API feriadosapi.com, endpoint por cidade
|
||||
// (GET /feriados/cidade/{ibge}?ano=AAAA), que devolve os feriados nacionais e
|
||||
// os municipais do ano.
|
||||
package feriados
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
// apiDateLayout é o formato das datas devolvidas pela API (dd/mm/aaaa).
|
||||
const apiDateLayout = "02/01/2006"
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
location string // código IBGE da cidade
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey, location string) *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
location: strings.TrimSpace(location),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c.baseURL != "" && c.apiKey != "" && c.location != ""
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Feriados []struct {
|
||||
Data string `json:"data"`
|
||||
Nome string `json:"nome"`
|
||||
} `json:"feriados"`
|
||||
}
|
||||
|
||||
// FetchYear devolve os feriados do ano para a cidade configurada.
|
||||
func (c *Client) FetchYear(ctx context.Context, year int) ([]domain.Holiday, error) {
|
||||
if !c.Configured() {
|
||||
return nil, domain.ErrNotConfigured
|
||||
}
|
||||
if _, err := strconv.Atoi(c.location); err != nil {
|
||||
return nil, fmt.Errorf("BP_HOLIDAY_LOCATION deve ser o código IBGE da cidade (ex.: 3106200), não %q", c.location)
|
||||
}
|
||||
endpoint, err := url.Parse(c.baseURL + "/feriados/cidade/" + url.PathEscape(c.location))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("URL da API de feriados: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("ano", strconv.Itoa(year))
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
return nil, fmt.Errorf("consultar API de feriados: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("API de feriados retornou HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload response
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("resposta da API de feriados ilegível: %w", err)
|
||||
}
|
||||
|
||||
found := map[string]string{}
|
||||
for _, item := range payload.Feriados {
|
||||
parsed, err := time.Parse(apiDateLayout, strings.TrimSpace(item.Data))
|
||||
if err != nil || parsed.Year() != year {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(item.Nome)
|
||||
if name == "" {
|
||||
name = "Feriado"
|
||||
}
|
||||
found[parsed.Format(domain.DateLayout)] = name
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil, fmt.Errorf("a API de feriados não retornou datas para %d", year)
|
||||
}
|
||||
|
||||
holidays := make([]domain.Holiday, 0, len(found))
|
||||
for day, name := range found {
|
||||
holidays = append(holidays, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceAPI})
|
||||
}
|
||||
return holidays, nil
|
||||
}
|
||||
52
backend/internal/adapters/feriados/client_test.go
Normal file
52
backend/internal/adapters/feriados/client_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package feriados
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchYear(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/feriados/cidade/3106200" || r.URL.Query().Get("ano") != "2026" {
|
||||
t.Errorf("requisição inesperada: %s", r.URL)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer chave" {
|
||||
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"feriados":[
|
||||
{"data":"01/01/2026","nome":"Ano Novo","tipo":"NACIONAL"},
|
||||
{"data":"15/08/2026","nome":"Assunção de Nossa Senhora","tipo":"MUNICIPAL"},
|
||||
{"data":"31/12/2025","nome":"Outro ano"},
|
||||
{"data":"lixo","nome":"Inválido"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := NewClient(srv.URL, "chave", "3106200").FetchYear(context.Background(), 2026)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
days := map[string]string{}
|
||||
for _, h := range got {
|
||||
days[h.Day] = h.Name
|
||||
}
|
||||
if len(days) != 2 || days["2026-01-01"] != "Ano Novo" || days["2026-08-15"] != "Assunção de Nossa Senhora" {
|
||||
t.Fatalf("feriados = %v", days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchYearErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := NewClient(srv.URL, "chave", "3106200").FetchYear(context.Background(), 2026); err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("esperava HTTP 401, veio %v", err)
|
||||
}
|
||||
if _, err := NewClient(srv.URL, "chave", "MG-belo-horizonte").FetchYear(context.Background(), 2026); err == nil || !strings.Contains(err.Error(), "IBGE") {
|
||||
t.Fatalf("esperava erro de código IBGE, veio %v", err)
|
||||
}
|
||||
}
|
||||
290
backend/internal/adapters/pontomais/client.go
Normal file
290
backend/internal/adapters/pontomais/client.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Package pontomais é o adapter de saída para a API da PontoMais.
|
||||
//
|
||||
// Contratos confirmados no Modelo: POST /api/auth/sign_in devolve token e
|
||||
// client_id; GET /api/time_cards/current/last_cached devolve os registros
|
||||
// recentes. A API não informa a validade do token: a sessão fica só em
|
||||
// memória e é renovada quando o histórico responde 401/403.
|
||||
package pontomais
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthRejected: a PontoMais recusou o login (credencial inválida ou bloqueada).
|
||||
ErrAuthRejected = errors.New("a PontoMais recusou a credencial")
|
||||
)
|
||||
|
||||
// Decrypter decifra a senha guardada; satisfeito por *crypto.Keyring.
|
||||
type Decrypter interface {
|
||||
Decrypt(data []byte, version int16) (string, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
authURL string
|
||||
historyURL string
|
||||
keys Decrypter
|
||||
retryDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[int64]cachedSession
|
||||
}
|
||||
|
||||
type session struct {
|
||||
client string
|
||||
token string
|
||||
uid string
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
session session
|
||||
credentialsUpdated time.Time
|
||||
}
|
||||
|
||||
func NewClient(authURL, historyURL string, timeout time.Duration, keys Decrypter) *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: timeout},
|
||||
authURL: authURL,
|
||||
historyURL: historyURL,
|
||||
keys: keys,
|
||||
retryDelay: 3 * time.Second,
|
||||
sessions: make(map[int64]cachedSession),
|
||||
}
|
||||
}
|
||||
|
||||
// History devolve os pontos recentes do usuário. Faz uma nova tentativa rápida
|
||||
// em falhas transitórias (rede, timeout, 5xx) e refaz o login uma vez após 401/403.
|
||||
func (c *Client) History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
||||
points, err := c.historyOnce(ctx, cred, loc)
|
||||
if err == nil || !isTransient(err) {
|
||||
return points, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(c.retryDelay):
|
||||
}
|
||||
return c.historyOnce(ctx, cred, loc)
|
||||
}
|
||||
|
||||
func (c *Client) historyOnce(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
||||
s, ok := c.cached(cred)
|
||||
if !ok {
|
||||
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.store(cred, s)
|
||||
}
|
||||
|
||||
points, err := c.fetchHistory(ctx, s, loc)
|
||||
if !errors.Is(err, errUnauthorized) {
|
||||
return points, err
|
||||
}
|
||||
|
||||
// Token recusado: descarta a sessão e tenta uma vez com login novo.
|
||||
c.Invalidate(cred.UserID)
|
||||
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.store(cred, s)
|
||||
points, err = c.fetchHistory(ctx, s, loc)
|
||||
if errors.Is(err, errUnauthorized) {
|
||||
return nil, fmt.Errorf("%w: token recusado mesmo após novo login", ErrAuthRejected)
|
||||
}
|
||||
return points, err
|
||||
}
|
||||
|
||||
// Validate testa uma credencial ainda não gravada: login + uma leitura do histórico.
|
||||
func (c *Client) Validate(ctx context.Context, email, password string, loc *time.Location) error {
|
||||
s, err := c.signIn(ctx, email, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.fetchHistory(ctx, s, loc)
|
||||
if errors.Is(err, errUnauthorized) {
|
||||
return fmt.Errorf("%w: histórico recusou o token", ErrAuthRejected)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate descarta a sessão em memória (ex.: credencial alterada).
|
||||
func (c *Client) Invalidate(userID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.sessions, userID)
|
||||
}
|
||||
|
||||
func (c *Client) cached(cred domain.Credential) (session, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.sessions[cred.UserID]
|
||||
if !ok || !item.credentialsUpdated.Equal(cred.UpdatedAt) {
|
||||
return session{}, false
|
||||
}
|
||||
return item.session, true
|
||||
}
|
||||
|
||||
func (c *Client) store(cred domain.Credential, s session) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.sessions[cred.UserID] = cachedSession{session: s, credentialsUpdated: cred.UpdatedAt}
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"client_id"`
|
||||
Data struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) signIn(ctx context.Context, email, password string) (session, error) {
|
||||
body, err := json.Marshal(authRequest{Login: email, Password: password})
|
||||
if err != nil {
|
||||
return session{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return session{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return session{}, networkError("login", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden ||
|
||||
resp.StatusCode == http.StatusUnprocessableEntity:
|
||||
return session{}, fmt.Errorf("%w (login HTTP %d)", ErrAuthRejected, resp.StatusCode)
|
||||
case resp.StatusCode < 200 || resp.StatusCode > 299:
|
||||
return session{}, &statusError{op: "login", code: resp.StatusCode}
|
||||
}
|
||||
|
||||
var payload authResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return session{}, fmt.Errorf("resposta de login ilegível: %w", err)
|
||||
}
|
||||
if payload.Token == "" || payload.ClientID == "" {
|
||||
return session{}, errors.New("resposta de login sem token ou client_id")
|
||||
}
|
||||
uid := payload.Data.Login
|
||||
if uid == "" {
|
||||
uid = email
|
||||
}
|
||||
return session{client: payload.ClientID, token: payload.Token, uid: uid}, nil
|
||||
}
|
||||
|
||||
type historyResponse struct {
|
||||
TimeCards []struct {
|
||||
Date string `json:"date"`
|
||||
Time string `json:"time"`
|
||||
Disabled bool `json:"disabled"`
|
||||
} `json:"time_cards"`
|
||||
}
|
||||
|
||||
var errUnauthorized = errors.New("token não autorizado")
|
||||
|
||||
func (c *Client) fetchHistory(ctx context.Context, s session, loc *time.Location) ([]domain.Point, error) {
|
||||
if _, err := url.Parse(c.historyURL); err != nil {
|
||||
return nil, fmt.Errorf("URL do histórico inválida: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.historyURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("access-token", s.token)
|
||||
req.Header.Set("client", s.client)
|
||||
req.Header.Set("uid", s.uid)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, networkError("histórico", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, &statusError{op: "histórico", code: resp.StatusCode}
|
||||
}
|
||||
|
||||
var payload historyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("resposta de histórico ilegível: %w", err)
|
||||
}
|
||||
|
||||
points := make([]domain.Point, 0, len(payload.TimeCards))
|
||||
for _, raw := range payload.TimeCards {
|
||||
if raw.Disabled {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(raw.Date) + " " + strings.TrimSpace(raw.Time)
|
||||
occurredAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: horário %q", domain.ErrInvalidPointHistory, value)
|
||||
}
|
||||
points = append(points, domain.Point{OccurredAt: occurredAt})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
type statusError struct {
|
||||
op string
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *statusError) Error() string { return fmt.Sprintf("PontoMais: %s retornou HTTP %d", e.op, e.code) }
|
||||
|
||||
type transientError struct{ err error }
|
||||
|
||||
func (e *transientError) Error() string { return e.err.Error() }
|
||||
func (e *transientError) Unwrap() error { return e.err }
|
||||
|
||||
// networkError remove a URL do erro (por segurança e legibilidade) e marca como transitório.
|
||||
func networkError(op string, err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
return &transientError{err: fmt.Errorf("PontoMais: falha de rede no %s: %w", op, err)}
|
||||
}
|
||||
|
||||
func isTransient(err error) bool {
|
||||
var t *transientError
|
||||
if errors.As(err, &t) {
|
||||
return true
|
||||
}
|
||||
var s *statusError
|
||||
return errors.As(err, &s) && (s.code >= 500 || s.code == http.StatusTooManyRequests)
|
||||
}
|
||||
141
backend/internal/adapters/pontomais/client_test.go
Normal file
141
backend/internal/adapters/pontomais/client_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package pontomais
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
type plainKeys struct{}
|
||||
|
||||
func (plainKeys) Decrypt(data []byte, _ int16) (string, error) { return string(data), nil }
|
||||
|
||||
var saoPaulo = domain.LoadLocation("America/Sao_Paulo")
|
||||
|
||||
func newServer(t *testing.T, historyStatus func(call int32) int) (*httptest.Server, *int32, *int32) {
|
||||
t.Helper()
|
||||
var logins, histories int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/auth/sign_in", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&logins, 1)
|
||||
var body authRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Password != "certa" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"token": "tok", "client_id": "cli", "data": map[string]string{"login": body.Login}})
|
||||
})
|
||||
mux.HandleFunc("GET /api/time_cards/current/last_cached", func(w http.ResponseWriter, r *http.Request) {
|
||||
call := atomic.AddInt32(&histories, 1)
|
||||
if status := historyStatus(call); status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("access-token") != "tok" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"time_cards": []map[string]any{
|
||||
{"date": "2026-10-14", "time": "08:00:00"},
|
||||
{"date": "2026-10-14", "time": "12:00:00"},
|
||||
{"date": "2026-10-14", "time": "12:30:00", "disabled": true},
|
||||
}})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server, &logins, &histories
|
||||
}
|
||||
|
||||
func newClient(server *httptest.Server) *Client {
|
||||
c := NewClient(server.URL+"/api/auth/sign_in", server.URL+"/api/time_cards/current/last_cached", 2*time.Second, plainKeys{})
|
||||
c.retryDelay = time.Millisecond
|
||||
return c
|
||||
}
|
||||
|
||||
func cred(password string) domain.Credential {
|
||||
return domain.Credential{UserID: 1, APIEmail: "a@b.c", EncryptedPassword: []byte(password), UpdatedAt: time.Unix(100, 0)}
|
||||
}
|
||||
|
||||
func TestHistoryParsesAndCachesSession(t *testing.T) {
|
||||
server, logins, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
c := newClient(server)
|
||||
|
||||
points, err := c.History(context.Background(), cred("certa"), saoPaulo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(points) != 2 {
|
||||
t.Fatalf("esperados 2 pontos (o desabilitado é ignorado), vieram %d", len(points))
|
||||
}
|
||||
if got := points[0].OccurredAt.In(saoPaulo).Format("15:04"); got != "08:00" {
|
||||
t.Errorf("primeiro ponto = %s", got)
|
||||
}
|
||||
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *logins != 1 {
|
||||
t.Errorf("a sessão deveria ser reaproveitada; logins = %d", *logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRetriesTransientError(t *testing.T) {
|
||||
server, _, histories := newServer(t, func(call int32) int {
|
||||
if call == 1 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return http.StatusOK
|
||||
})
|
||||
if _, err := newClient(server).History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatalf("a segunda tentativa deveria funcionar: %v", err)
|
||||
}
|
||||
if *histories != 2 {
|
||||
t.Errorf("esperadas 2 chamadas ao histórico, vieram %d", *histories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRejectedCredential(t *testing.T) {
|
||||
server, _, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
_, err := newClient(server).History(context.Background(), cred("errada"), saoPaulo)
|
||||
if !errors.Is(err, ErrAuthRejected) {
|
||||
t.Fatalf("esperado ErrAuthRejected, veio %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRelogsAfterUnauthorized(t *testing.T) {
|
||||
server, logins, _ := newServer(t, func(call int32) int {
|
||||
if call == 2 {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
return http.StatusOK
|
||||
})
|
||||
c := newClient(server)
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatalf("deveria refazer o login após 401: %v", err)
|
||||
}
|
||||
if *logins != 2 {
|
||||
t.Errorf("esperados 2 logins, vieram %d", *logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
server, _, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
c := newClient(server)
|
||||
if err := c.Validate(context.Background(), "a@b.c", "certa", saoPaulo); err != nil {
|
||||
t.Fatalf("credencial certa: %v", err)
|
||||
}
|
||||
if err := c.Validate(context.Background(), "a@b.c", "errada", saoPaulo); !errors.Is(err, ErrAuthRejected) {
|
||||
t.Fatalf("credencial errada: %v", err)
|
||||
}
|
||||
}
|
||||
78
backend/internal/adapters/postgres/audit.go
Normal file
78
backend/internal/adapters/postgres/audit.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) InsertAudit(ctx context.Context, e domain.AuditEntry) error {
|
||||
details := e.Details
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO audit_logs (source, actor_user_id, actor_account, target_user_id, event_type, success, details)
|
||||
VALUES ($1::text::audit_source, $2, $3, $4, $5, $6, $7::text::jsonb)`,
|
||||
string(e.Source), e.ActorUserID, e.ActorAccount, e.TargetUserID, e.EventType, e.Success, string(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
type AuditFilter struct {
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Source *string
|
||||
EventType *string
|
||||
Success *bool
|
||||
TargetUserID *int64
|
||||
ActorAccount *int64
|
||||
Before *int64 // cursor: id do último item da página anterior
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]domain.AuditLog, error) {
|
||||
if f.Limit <= 0 || f.Limit > 200 {
|
||||
f.Limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT a.id, a.occurred_at, a.source, a.actor_user_id, au.display_name, a.actor_account, wa.username::text,
|
||||
a.target_user_id, tu.display_name, a.event_type, a.success, a.details::text
|
||||
FROM audit_logs a
|
||||
LEFT JOIN users au ON au.id = a.actor_user_id
|
||||
LEFT JOIN web_accounts wa ON wa.id = a.actor_account
|
||||
LEFT JOIN users tu ON tu.id = a.target_user_id
|
||||
WHERE ($1::timestamptz IS NULL OR a.occurred_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR a.occurred_at < $2)
|
||||
AND ($3::text IS NULL OR a.source::text = $3)
|
||||
AND ($4::text IS NULL OR a.event_type = $4)
|
||||
AND ($5::boolean IS NULL OR a.success = $5)
|
||||
AND ($6::bigint IS NULL OR a.target_user_id = $6)
|
||||
AND ($7::bigint IS NULL OR a.actor_account = $7)
|
||||
AND ($8::bigint IS NULL OR a.id < $8)
|
||||
ORDER BY a.id DESC
|
||||
LIMIT $9`,
|
||||
f.From, f.To, f.Source, f.EventType, f.Success, f.TargetUserID, f.ActorAccount, f.Before, f.Limit)
|
||||
return collect(rows, err, func(row scanner) (domain.AuditLog, error) {
|
||||
var l domain.AuditLog
|
||||
var source, details string
|
||||
if err := row.Scan(&l.ID, &l.OccurredAt, &source, &l.ActorUserID, &l.ActorUserName, &l.ActorAccount,
|
||||
&l.ActorUsername, &l.TargetUserID, &l.TargetUserName, &l.EventType, &l.Success, &details); err != nil {
|
||||
return l, err
|
||||
}
|
||||
l.Source = domain.AuditSource(source)
|
||||
l.Details = map[string]any{}
|
||||
_ = json.Unmarshal([]byte(details), &l.Details)
|
||||
return l, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAuditBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM audit_logs WHERE occurred_at < $1`, before)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
91
backend/internal/adapters/postgres/credentials.go
Normal file
91
backend/internal/adapters/postgres/credentials.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func credentialCols(p string) string {
|
||||
return cols(p, "user_id", "api_email", "encrypted_password", "key_version", "last_validated_at",
|
||||
"last_error", "consecutive_failures", "last_failure_at", "updated_at")
|
||||
}
|
||||
|
||||
type credentialScan struct {
|
||||
c domain.Credential
|
||||
}
|
||||
|
||||
func (s *credentialScan) dest() []any {
|
||||
return []any{&s.c.UserID, &s.c.APIEmail, &s.c.EncryptedPassword, &s.c.KeyVersion, &s.c.LastValidatedAt,
|
||||
&s.c.LastError, &s.c.ConsecutiveFailures, &s.c.LastFailureAt, &s.c.UpdatedAt}
|
||||
}
|
||||
|
||||
func (s *credentialScan) result() domain.Credential { return s.c }
|
||||
|
||||
func readCredential(row scanner) (domain.Credential, error) {
|
||||
var cs credentialScan
|
||||
if err := row.Scan(cs.dest()...); err != nil {
|
||||
return domain.Credential{}, mapErr(err)
|
||||
}
|
||||
return cs.result(), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCredential(ctx context.Context, userID int64) (domain.Credential, error) {
|
||||
return readCredential(s.pool.QueryRow(ctx, `SELECT `+credentialCols("")+` FROM point_credentials WHERE user_id = $1`, userID))
|
||||
}
|
||||
|
||||
// SaveCredential grava uma credencial já validada na PontoMais e zera o histórico de falhas.
|
||||
func (s *Store) SaveCredential(ctx context.Context, userID int64, email string, encrypted []byte, keyVersion int16, validatedAt time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO point_credentials (user_id, api_email, encrypted_password, key_version, last_validated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
api_email = EXCLUDED.api_email,
|
||||
encrypted_password = EXCLUDED.encrypted_password,
|
||||
key_version = EXCLUDED.key_version,
|
||||
last_validated_at = EXCLUDED.last_validated_at,
|
||||
last_error = NULL,
|
||||
consecutive_failures = 0,
|
||||
last_failure_at = NULL,
|
||||
updated_at = now()`,
|
||||
userID, email, encrypted, keyVersion, validatedAt)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
// MarkCredentialValidated registra um teste bem-sucedido da credencial atual.
|
||||
func (s *Store) MarkCredentialValidated(ctx context.Context, userID int64, at time.Time) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE point_credentials SET last_validated_at = $2, last_error = NULL WHERE user_id = $1`, userID, at))
|
||||
}
|
||||
|
||||
// RecordCredentialFailure soma uma falha consecutiva e devolve o total (9.4).
|
||||
func (s *Store) RecordCredentialFailure(ctx context.Context, userID int64, message string, at time.Time) (int, error) {
|
||||
var failures int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE point_credentials
|
||||
SET consecutive_failures = consecutive_failures + 1, last_error = $2, last_failure_at = $3
|
||||
WHERE user_id = $1
|
||||
RETURNING consecutive_failures`, userID, message, at).Scan(&failures)
|
||||
return failures, mapErr(err)
|
||||
}
|
||||
|
||||
// ResetCredentialFailures zera o contador após uma consulta bem-sucedida.
|
||||
func (s *Store) ResetCredentialFailures(ctx context.Context, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE point_credentials SET consecutive_failures = 0, last_error = NULL
|
||||
WHERE user_id = $1 AND (consecutive_failures > 0 OR last_error IS NOT NULL)`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListCredentials(ctx context.Context) ([]domain.Credential, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+credentialCols("")+` FROM point_credentials ORDER BY user_id`)
|
||||
return collect(rows, err, readCredential)
|
||||
}
|
||||
|
||||
// UpdateCredentialCipher regrava a senha cifrada com outra chave (rotação).
|
||||
func (s *Store) UpdateCredentialCipher(ctx context.Context, userID int64, encrypted []byte, keyVersion int16) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE point_credentials SET encrypted_password = $2, key_version = $3, updated_at = now() WHERE user_id = $1`,
|
||||
userID, encrypted, keyVersion))
|
||||
}
|
||||
64
backend/internal/adapters/postgres/days.go
Normal file
64
backend/internal/adapters/postgres/days.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
const dayCols = `user_id, to_char(day, 'YYYY-MM-DD'), status, zero_point_checks, last_point_count,
|
||||
next_check_at, resumed_by, updated_at`
|
||||
|
||||
func readDay(row scanner) (domain.UserDay, error) {
|
||||
var d domain.UserDay
|
||||
var status string
|
||||
var resumed *string
|
||||
if err := row.Scan(&d.UserID, &d.Day, &status, &d.ZeroPointChecks, &d.LastPointCount,
|
||||
&d.NextCheckAt, &resumed, &d.UpdatedAt); err != nil {
|
||||
return d, mapErr(err)
|
||||
}
|
||||
d.Status = domain.DayStatus(status)
|
||||
if resumed != nil {
|
||||
r := domain.ResumeSource(*resumed)
|
||||
d.ResumedBy = &r
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetUserDay devolve a situação do dia; found=false se o dia ainda não foi consultado.
|
||||
func (s *Store) GetUserDay(ctx context.Context, userID int64, day string) (domain.UserDay, bool, error) {
|
||||
d, err := readDay(s.pool.QueryRow(ctx, `SELECT `+dayCols+` FROM user_days WHERE user_id = $1 AND day = $2::text::date`, userID, day))
|
||||
if err == domain.ErrNotFound {
|
||||
return domain.NewUserDay(userID, day), false, nil
|
||||
}
|
||||
return d, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) SaveUserDay(ctx context.Context, d domain.UserDay) error {
|
||||
var resumed *string
|
||||
if d.ResumedBy != nil {
|
||||
v := string(*d.ResumedBy)
|
||||
resumed = &v
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_days (user_id, day, status, zero_point_checks, last_point_count, next_check_at, resumed_by)
|
||||
VALUES ($1, $2::text::date, $3::text::day_status, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, day) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
zero_point_checks = EXCLUDED.zero_point_checks,
|
||||
last_point_count = EXCLUDED.last_point_count,
|
||||
next_check_at = EXCLUDED.next_check_at,
|
||||
resumed_by = EXCLUDED.resumed_by`,
|
||||
d.UserID, d.Day, string(d.Status), d.ZeroPointChecks, d.LastPointCount, d.NextCheckAt, resumed)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
// ResetNextChecks antecipa as próximas consultas do dia para agora; usado quando a
|
||||
// janela muda (9.13), para a reconciliação recalcular tudo no próximo tick. Inclui
|
||||
// o dia anterior para cobrir usuários em fusos diferentes perto da meia-noite.
|
||||
func (s *Store) ResetNextChecks(ctx context.Context, day string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_days SET next_check_at = now()
|
||||
WHERE day >= $1::text::date - 1 AND next_check_at IS NOT NULL`, day)
|
||||
return err
|
||||
}
|
||||
66
backend/internal/adapters/postgres/holidays.go
Normal file
66
backend/internal/adapters/postgres/holidays.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func readHoliday(row scanner) (domain.Holiday, error) {
|
||||
var h domain.Holiday
|
||||
err := row.Scan(&h.Day, &h.Name, &h.Source)
|
||||
return h, mapErr(err)
|
||||
}
|
||||
|
||||
func (s *Store) IsHoliday(ctx context.Context, day string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM holidays WHERE day = $1::text::date)`, day).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *Store) HasSyncedHolidays(ctx context.Context, year int) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM holidays
|
||||
WHERE source <> 'manual' AND day >= make_date($1::int, 1, 1) AND day < make_date($1::int + 1, 1, 1)
|
||||
)`, year).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *Store) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT to_char(day, 'YYYY-MM-DD'), name, source FROM holidays
|
||||
WHERE day >= make_date($1::int, 1, 1) AND day < make_date($1::int + 1, 1, 1)
|
||||
ORDER BY day`, year)
|
||||
return collect(rows, err, readHoliday)
|
||||
}
|
||||
|
||||
// InsertHolidays grava os feriados da API sem sobrescrever datas existentes
|
||||
// (feriados manuais nunca são substituídos). Devolve quantos foram inseridos.
|
||||
func (s *Store) InsertHolidays(ctx context.Context, holidays []domain.Holiday) (int64, error) {
|
||||
var inserted int64
|
||||
err := s.inTx(ctx, func(q querier) error {
|
||||
for _, h := range holidays {
|
||||
tag, err := q.Exec(ctx, `
|
||||
INSERT INTO holidays (day, name, source) VALUES ($1::text::date, $2, $3)
|
||||
ON CONFLICT (day) DO NOTHING`, h.Day, h.Name, h.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inserted += tag.RowsAffected()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return inserted, err
|
||||
}
|
||||
|
||||
func (s *Store) AddHoliday(ctx context.Context, h domain.Holiday) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO holidays (day, name, source) VALUES ($1::text::date, $2, $3)`, h.Day, h.Name, h.Source)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
// DeleteManualHoliday remove só feriados cadastrados manualmente.
|
||||
func (s *Store) DeleteManualHoliday(ctx context.Context, day string) error {
|
||||
return expectOne(s.pool.Exec(ctx, `DELETE FROM holidays WHERE day = $1::text::date AND source = 'manual'`, day))
|
||||
}
|
||||
50
backend/internal/adapters/postgres/migrate.go
Normal file
50
backend/internal/adapters/postgres/migrate.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // registra o driver "pgx" no database/sql
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"botpontos/db/migrations"
|
||||
"botpontos/internal/config"
|
||||
)
|
||||
|
||||
// Migrate executa o goose com as migrations embutidas no binário.
|
||||
// Deve rodar com o usuário dono do banco, não com o usuário da aplicação.
|
||||
func Migrate(ctx context.Context, cfg config.DB, command string, logger *slog.Logger) error {
|
||||
db, err := sql.Open("pgx", cfg.DSN())
|
||||
if err != nil {
|
||||
return fmt.Errorf("abrir conexão para migrations: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := waitFor(ctx, logger, db.PingContext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
goose.SetBaseFS(migrations.FS)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "up":
|
||||
err = goose.UpContext(ctx, db, ".")
|
||||
case "status":
|
||||
err = goose.StatusContext(ctx, db, ".")
|
||||
case "down":
|
||||
err = goose.DownContext(ctx, db, ".")
|
||||
default:
|
||||
return fmt.Errorf("subcomando de migrate desconhecido: %s (use up, status ou down)", command)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", command, err)
|
||||
}
|
||||
|
||||
logger.Info("migrations concluídas", "command", command)
|
||||
return nil
|
||||
}
|
||||
179
backend/internal/adapters/postgres/notifications.go
Normal file
179
backend/internal/adapters/postgres/notifications.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func notificationCols(p string) string {
|
||||
return cols(p, "id", "user_id") + ", to_char(" + p + "reference_date, 'YYYY-MM-DD'), " +
|
||||
cols(p, "type", "scheduled_at", "predicted_at", "status", "attempts", "locked_at", "sent_at",
|
||||
"telegram_message_id", "error_message", "created_at", "updated_at")
|
||||
}
|
||||
|
||||
func readNotification(row scanner) (domain.Notification, error) {
|
||||
var n domain.Notification
|
||||
var typ, status string
|
||||
if err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status,
|
||||
&n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return n, mapErr(err)
|
||||
}
|
||||
n.Type = domain.NotificationType(typ)
|
||||
n.Status = domain.NotificationStatus(status)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetNotification(ctx context.Context, id int64) (domain.Notification, error) {
|
||||
return readNotification(s.pool.QueryRow(ctx, `SELECT `+notificationCols("")+` FROM notifications WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
// FindNotification devolve o lembrete do usuário para o dia e tipo; found=false se não existir.
|
||||
func (s *Store) FindNotification(ctx context.Context, userID int64, day string, t domain.NotificationType) (domain.Notification, bool, error) {
|
||||
n, err := readNotification(s.pool.QueryRow(ctx, `
|
||||
SELECT `+notificationCols("")+` FROM notifications
|
||||
WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3`, userID, day, string(t)))
|
||||
if err == domain.ErrNotFound {
|
||||
return n, false, nil
|
||||
}
|
||||
return n, err == nil, err
|
||||
}
|
||||
|
||||
// InsertNotification cria o lembrete se ainda não existir um para (usuário, dia, tipo).
|
||||
// inserted=false devolve o lembrete que já existia, sem alterá-lo.
|
||||
func (s *Store) InsertNotification(ctx context.Context, n domain.Notification) (domain.Notification, bool, error) {
|
||||
created, err := readNotification(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO notifications (user_id, reference_date, type, scheduled_at, predicted_at, status, error_message)
|
||||
VALUES ($1, $2::text::date, $3::text::notification_type, $4, $5, $6::text::notification_status, $7)
|
||||
ON CONFLICT (user_id, reference_date, type) DO NOTHING
|
||||
RETURNING `+notificationCols(""),
|
||||
n.UserID, n.ReferenceDate, string(n.Type), n.ScheduledAt, n.PredictedAt, string(n.Status), n.ErrorMessage))
|
||||
if err == domain.ErrNotFound {
|
||||
existing, _, err := s.FindNotification(ctx, n.UserID, n.ReferenceDate, n.Type)
|
||||
return existing, false, err
|
||||
}
|
||||
return created, err == nil, err
|
||||
}
|
||||
|
||||
// CancelScheduled cancela o lembrete agendado de um tipo no dia.
|
||||
func (s *Store) CancelScheduled(ctx context.Context, userID int64, day string, t domain.NotificationType) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET status = 'cancelled'
|
||||
WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3 AND status = 'scheduled'`,
|
||||
userID, day, string(t))
|
||||
return tag.RowsAffected() > 0, err
|
||||
}
|
||||
|
||||
// CancelScheduledFrom cancela todos os lembretes agendados do usuário a partir do dia.
|
||||
func (s *Store) CancelScheduledFrom(ctx context.Context, userID int64, day string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET status = 'cancelled'
|
||||
WHERE user_id = $1 AND reference_date >= $2::text::date AND status = 'scheduled'`, userID, day)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
|
||||
// CancelNotification cancela um lembrete específico, só se ainda estiver agendado.
|
||||
func (s *Store) CancelNotification(ctx context.Context, id int64) (domain.Notification, error) {
|
||||
return readNotification(s.pool.QueryRow(ctx, `
|
||||
UPDATE notifications SET status = 'cancelled', error_message = 'cancelado pelo painel'
|
||||
WHERE id = $1 AND status = 'scheduled'
|
||||
RETURNING `+notificationCols(""), id))
|
||||
}
|
||||
|
||||
// ExpireOverdue marca como expired o que passou da tolerância de atraso (9.3, etapa 1).
|
||||
func (s *Store) ExpireOverdue(ctx context.Context, before time.Time) ([]domain.Notification, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
UPDATE notifications SET status = 'expired', error_message = 'atraso acima da tolerância'
|
||||
WHERE status = 'scheduled' AND scheduled_at < $1
|
||||
RETURNING `+notificationCols(""), before)
|
||||
return collect(rows, err, readNotification)
|
||||
}
|
||||
|
||||
// ClaimDue reserva os lembretes vencidos (scheduled → sending) sem duplicidade (6.3, etapa 2).
|
||||
func (s *Store) ClaimDue(ctx context.Context, now time.Time, limit int) ([]domain.Notification, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH due AS (
|
||||
SELECT id FROM notifications
|
||||
WHERE status = 'scheduled' AND scheduled_at <= $1
|
||||
ORDER BY scheduled_at
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE notifications n
|
||||
SET status = 'sending', locked_at = now(), attempts = n.attempts + 1
|
||||
FROM due WHERE n.id = due.id
|
||||
RETURNING `+notificationCols("n."), now, limit)
|
||||
return collect(rows, err, readNotification)
|
||||
}
|
||||
|
||||
// ClaimFailed reserva um lembrete failed para o reenvio manual (9.5). A troca
|
||||
// de status é atômica: duplo clique ou dois admins resultam num envio só.
|
||||
func (s *Store) ClaimFailed(ctx context.Context, id int64) (domain.Notification, error) {
|
||||
return readNotification(s.pool.QueryRow(ctx, `
|
||||
UPDATE notifications
|
||||
SET status = 'sending', locked_at = now(), attempts = attempts + 1, error_message = NULL
|
||||
WHERE id = $1 AND status = 'failed'
|
||||
RETURNING `+notificationCols(""), id))
|
||||
}
|
||||
|
||||
func (s *Store) MarkSent(ctx context.Context, id, messageID int64, at time.Time) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET status = 'sent', sent_at = $3, telegram_message_id = $2, error_message = NULL
|
||||
WHERE id = $1 AND status = 'sending'`, id, messageID, at))
|
||||
}
|
||||
|
||||
// MarkFinished encerra um lembrete em envio com failed, expired ou cancelled.
|
||||
func (s *Store) MarkFinished(ctx context.Context, id int64, status domain.NotificationStatus, message string) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET status = $2::text::notification_status, error_message = $3
|
||||
WHERE id = $1 AND status = 'sending'`, id, string(status), message))
|
||||
}
|
||||
|
||||
// FailStuckSending marca como failed o que está em sending há tempo demais (crash no envio).
|
||||
func (s *Store) FailStuckSending(ctx context.Context, before time.Time) ([]domain.Notification, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
UPDATE notifications SET status = 'failed', error_message = 'envio interrompido (reinício ou travamento)'
|
||||
WHERE status = 'sending' AND locked_at < $1
|
||||
RETURNING `+notificationCols(""), before)
|
||||
return collect(rows, err, readNotification)
|
||||
}
|
||||
|
||||
type NotificationView struct {
|
||||
Notification domain.Notification
|
||||
UserName string
|
||||
}
|
||||
|
||||
type NotificationFilter struct {
|
||||
Day *string
|
||||
Status *string
|
||||
Type *string
|
||||
UserID *int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (s *Store) ListNotifications(ctx context.Context, f NotificationFilter) ([]NotificationView, error) {
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
f.Limit = 200
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+notificationCols("n.")+`, u.display_name
|
||||
FROM notifications n JOIN users u ON u.id = n.user_id
|
||||
WHERE ($1::text IS NULL OR n.reference_date = $1::text::date)
|
||||
AND ($2::text IS NULL OR n.status::text = $2)
|
||||
AND ($3::text IS NULL OR n.type::text = $3)
|
||||
AND ($4::bigint IS NULL OR n.user_id = $4)
|
||||
ORDER BY n.reference_date DESC, n.scheduled_at DESC
|
||||
LIMIT $5`, f.Day, f.Status, f.Type, f.UserID, f.Limit)
|
||||
return collect(rows, err, func(row scanner) (NotificationView, error) {
|
||||
var v NotificationView
|
||||
var typ, status string
|
||||
n := &v.Notification
|
||||
err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status,
|
||||
&n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt,
|
||||
&v.UserName)
|
||||
n.Type = domain.NotificationType(typ)
|
||||
n.Status = domain.NotificationStatus(status)
|
||||
return v, err
|
||||
})
|
||||
}
|
||||
58
backend/internal/adapters/postgres/pool.go
Normal file
58
backend/internal/adapters/postgres/pool.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package postgres é o adapter de saída para o PostgreSQL.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"botpontos/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
connectAttempts = 10
|
||||
connectDelay = 3 * time.Second
|
||||
)
|
||||
|
||||
// OpenPool abre o pool e espera o Postgres responder, com algumas tentativas
|
||||
// para tolerar a ordem de subida dos containers.
|
||||
func OpenPool(ctx context.Context, cfg config.DB, logger *slog.Logger) (*pgxpool.Pool, error) {
|
||||
poolCfg, err := pgxpool.ParseConfig(cfg.DSN())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configuração do Postgres: %w", err)
|
||||
}
|
||||
poolCfg.MaxConns = cfg.MaxConns
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("criar pool do Postgres: %w", err)
|
||||
}
|
||||
if err := waitFor(ctx, logger, pool.Ping); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func waitFor(ctx context.Context, logger *slog.Logger, ping func(context.Context) error) error {
|
||||
var err error
|
||||
for attempt := 1; attempt <= connectAttempts; attempt++ {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err = ping(pingCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Warn("Postgres indisponível; nova tentativa", "attempt", attempt, "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(connectDelay):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("conectar ao Postgres após %d tentativas: %w", connectAttempts, err)
|
||||
}
|
||||
92
backend/internal/adapters/postgres/reports.go
Normal file
92
backend/internal/adapters/postgres/reports.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dashboard são os contadores da página inicial do painel.
|
||||
type Dashboard struct {
|
||||
UsersByStatus map[string]int
|
||||
NotificationsEnabled int
|
||||
PausedByAPIFailures int
|
||||
TodayByStatus map[string]int
|
||||
FailedLast7Days int
|
||||
ExpiredLast7Days int
|
||||
CustomWindows int
|
||||
}
|
||||
|
||||
func (s *Store) Dashboard(ctx context.Context, today string) (Dashboard, error) {
|
||||
d := Dashboard{UsersByStatus: map[string]int{}, TodayByStatus: map[string]int{}}
|
||||
|
||||
if err := s.countBy(ctx, `SELECT status::text, count(*) FROM users GROUP BY 1`, d.UsersByStatus); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := s.countBy(ctx, `SELECT status::text, count(*) FROM notifications WHERE reference_date = $1::text::date GROUP BY 1`,
|
||||
d.TodayByStatus, today); err != nil {
|
||||
return d, err
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT count(*) FROM users WHERE status = 'active' AND notifications_enabled),
|
||||
(SELECT count(*) FROM users WHERE status = 'active' AND notifications_paused_reason = 'api_failures'),
|
||||
(SELECT count(*) FROM notifications WHERE status = 'failed' AND reference_date >= $1::text::date - 7),
|
||||
(SELECT count(*) FROM notifications WHERE status = 'expired' AND reference_date >= $1::text::date - 7),
|
||||
(SELECT count(*) FROM work_schedules ws JOIN users u ON u.id = ws.user_id
|
||||
WHERE ws.query_window_start IS NOT NULL AND u.status = 'active')`, today).
|
||||
Scan(&d.NotificationsEnabled, &d.PausedByAPIFailures, &d.FailedLast7Days, &d.ExpiredLast7Days, &d.CustomWindows)
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (s *Store) countBy(ctx context.Context, sql string, out map[string]int, args ...any) error {
|
||||
rows, err := s.pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var n int
|
||||
if err := rows.Scan(&key, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
out[key] = n
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// ReminderSample é um lembrete de entrada2 recente, usado na pré-visualização
|
||||
// da janela (9.13): se o horário do aviso cai antes do início da nova janela,
|
||||
// ele teria saído atrasado ou expirado.
|
||||
type ReminderSample struct {
|
||||
UserID int64
|
||||
Timezone string
|
||||
Day string
|
||||
ScheduledAt time.Time
|
||||
CustomWindow bool
|
||||
}
|
||||
|
||||
func (s *Store) Entrada2Samples(ctx context.Context, sinceDay string) ([]ReminderSample, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT n.user_id, u.timezone, to_char(n.reference_date, 'YYYY-MM-DD'), n.scheduled_at,
|
||||
ws.query_window_start IS NOT NULL
|
||||
FROM notifications n
|
||||
JOIN users u ON u.id = n.user_id
|
||||
JOIN work_schedules ws ON ws.user_id = n.user_id
|
||||
WHERE n.type = 'entrada2' AND n.reference_date >= $1::text::date`, sinceDay)
|
||||
return collect(rows, err, func(row scanner) (ReminderSample, error) {
|
||||
var x ReminderSample
|
||||
err := row.Scan(&x.UserID, &x.Timezone, &x.Day, &x.ScheduledAt, &x.CustomWindow)
|
||||
return x, err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteNotificationsBefore(ctx context.Context, day string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM notifications WHERE reference_date < $1::text::date`, day)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserDaysBefore(ctx context.Context, day string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM user_days WHERE day < $1::text::date`, day)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
93
backend/internal/adapters/postgres/schedules.go
Normal file
93
backend/internal/adapters/postgres/schedules.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func scheduleCols(p string) string {
|
||||
return fmt.Sprintf(`%[1]suser_id, %[1]slunch_minutes, %[1]sdaily_work_minutes, %[1]sentrada2_lead_minutes,
|
||||
%[1]ssaida2_lead_minutes,
|
||||
to_char(%[1]squery_window_start, 'HH24:MI'), to_char(%[1]squery_window_end, 'HH24:MI'),
|
||||
to_char(%[1]sday_check_deadline, 'HH24:MI'), to_char(%[1]sno_points_recheck_until, 'HH24:MI')`, p)
|
||||
}
|
||||
|
||||
type scheduleScan struct {
|
||||
s domain.WorkSchedule
|
||||
start, end, deadline, recheckUntil *string
|
||||
}
|
||||
|
||||
func (s *scheduleScan) dest() []any {
|
||||
return []any{&s.s.UserID, &s.s.LunchMinutes, &s.s.DailyWorkMinutes, &s.s.Entrada2LeadMinutes,
|
||||
&s.s.Saida2LeadMinutes, &s.start, &s.end, &s.deadline, &s.recheckUntil}
|
||||
}
|
||||
|
||||
func (s *scheduleScan) result() (domain.WorkSchedule, error) {
|
||||
out := s.s
|
||||
if s.start == nil || s.end == nil || s.deadline == nil || s.recheckUntil == nil {
|
||||
return out, nil
|
||||
}
|
||||
var w domain.QueryWindow
|
||||
var err error
|
||||
if w.Start, err = domain.ParseClock(*s.start); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if w.End, err = domain.ParseClock(*s.end); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if w.DayCheckDeadline, err = domain.ParseClock(*s.deadline); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if w.NoPointsRecheckUntil, err = domain.ParseClock(*s.recheckUntil); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Window = &w
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSchedule devolve a jornada do usuário ou a padrão, se ainda não houver linha.
|
||||
func (s *Store) GetSchedule(ctx context.Context, userID int64) (domain.WorkSchedule, error) {
|
||||
var ss scheduleScan
|
||||
err := s.pool.QueryRow(ctx, `SELECT `+scheduleCols("")+` FROM work_schedules WHERE user_id = $1`, userID).Scan(ss.dest()...)
|
||||
if err != nil {
|
||||
if mapErr(err) == domain.ErrNotFound {
|
||||
return domain.DefaultSchedule(userID), nil
|
||||
}
|
||||
return domain.WorkSchedule{}, err
|
||||
}
|
||||
return ss.result()
|
||||
}
|
||||
|
||||
func (s *Store) SaveSchedule(ctx context.Context, sch domain.WorkSchedule) error {
|
||||
var start, end, deadline, recheck *string
|
||||
if w := sch.Window; w != nil {
|
||||
start, end = strPtr(w.Start.String()), strPtr(w.End.String())
|
||||
deadline, recheck = strPtr(w.DayCheckDeadline.String()), strPtr(w.NoPointsRecheckUntil.String())
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO work_schedules (user_id, lunch_minutes, daily_work_minutes, entrada2_lead_minutes, saida2_lead_minutes,
|
||||
query_window_start, query_window_end, day_check_deadline, no_points_recheck_until)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::text::time, $7::text::time, $8::text::time, $9::text::time)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
lunch_minutes = EXCLUDED.lunch_minutes,
|
||||
daily_work_minutes = EXCLUDED.daily_work_minutes,
|
||||
entrada2_lead_minutes = EXCLUDED.entrada2_lead_minutes,
|
||||
saida2_lead_minutes = EXCLUDED.saida2_lead_minutes,
|
||||
query_window_start = EXCLUDED.query_window_start,
|
||||
query_window_end = EXCLUDED.query_window_end,
|
||||
day_check_deadline = EXCLUDED.day_check_deadline,
|
||||
no_points_recheck_until = EXCLUDED.no_points_recheck_until`,
|
||||
sch.UserID, sch.LunchMinutes, sch.DailyWorkMinutes, sch.Entrada2LeadMinutes, sch.Saida2LeadMinutes,
|
||||
start, end, deadline, recheck)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
func (s *Store) CountCustomWindows(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM work_schedules ws JOIN users u ON u.id = ws.user_id
|
||||
WHERE ws.query_window_start IS NOT NULL AND u.status = 'active'`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
90
backend/internal/adapters/postgres/settings.go
Normal file
90
backend/internal/adapters/postgres/settings.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
// GetSettings devolve as configurações globais (app_settings) como texto.
|
||||
func (s *Store) GetSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT key, value #>> '{}' FROM app_settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var value *string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value != nil {
|
||||
out[key] = *value
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SeedSettings grava os valores padrão que ainda não existem (primeira subida).
|
||||
func (s *Store) SeedSettings(ctx context.Context, values map[string]string) error {
|
||||
return s.inTx(ctx, func(q querier) error {
|
||||
for key, value := range values {
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value) VALUES ($1, to_jsonb($2::text))
|
||||
ON CONFLICT (key) DO NOTHING`, key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SaveSettings(ctx context.Context, values map[string]string, accountID *int64) error {
|
||||
return s.inTx(ctx, func(q querier) error {
|
||||
for key, value := range values {
|
||||
if _, err := q.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_by_account) VALUES ($1, to_jsonb($2::text), $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by_account = EXCLUDED.updated_by_account`,
|
||||
key, value, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetState lê um estado operacional (service_state) para dentro de out.
|
||||
func (s *Store) GetState(ctx context.Context, key string, out any) (bool, error) {
|
||||
var raw string
|
||||
err := s.pool.QueryRow(ctx, `SELECT value::text FROM service_state WHERE key = $1`, key).Scan(&raw)
|
||||
if err != nil {
|
||||
if mapErr(err) == domain.ErrNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), out); err != nil {
|
||||
return false, fmt.Errorf("service_state %s ilegível: %w", key, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetState(ctx context.Context, key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO service_state (key, value) VALUES ($1, $2::text::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, string(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteState(ctx context.Context, key string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM service_state WHERE key = $1`, key)
|
||||
return err
|
||||
}
|
||||
125
backend/internal/adapters/postgres/store.go
Normal file
125
backend/internal/adapters/postgres/store.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
// Store reúne as consultas da aplicação. As queries são escritas à mão com pgx.
|
||||
//
|
||||
// Convenções:
|
||||
// - parâmetros de enum usam $n::text::<tipo>, para o pgx enviar texto;
|
||||
// - datas (AAAA-MM-DD) trafegam como string, com $n::text::date e to_char na leitura;
|
||||
// - filtros opcionais usam ($n::<tipo> IS NULL OR ...) com ponteiros nil.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// Ping é usado pelos healthchecks.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
type querier interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// inTx executa fn numa transação; qualquer erro desfaz tudo.
|
||||
func (s *Store) inTx(ctx context.Context, fn func(q querier) error) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// mapErr traduz erros do pgx para os erros do domínio.
|
||||
func mapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func expectOne(tag pgconn.CommandTag, err error) error {
|
||||
if err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collect lê todas as linhas com a função de leitura informada.
|
||||
func collect[T any](rows pgx.Rows, err error, read func(scanner) (T, error)) ([]T, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []T
|
||||
for rows.Next() {
|
||||
item, err := read(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cols(prefix string, names ...string) string {
|
||||
out := ""
|
||||
for i, n := range names {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += prefix + n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func pauseReasonPtr(r *domain.PauseReason) *string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
v := string(*r)
|
||||
return &v
|
||||
}
|
||||
198
backend/internal/adapters/postgres/users.go
Normal file
198
backend/internal/adapters/postgres/users.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func userCols(p string) string {
|
||||
return cols(p, "id", "telegram_chat_id", "display_name", "status", "is_bot_admin",
|
||||
"notifications_enabled", "notifications_paused_reason", "timezone", "created_at", "updated_at")
|
||||
}
|
||||
|
||||
type userScan struct {
|
||||
u domain.User
|
||||
status string
|
||||
reason *string
|
||||
}
|
||||
|
||||
func (s *userScan) dest() []any {
|
||||
return []any{&s.u.ID, &s.u.TelegramChatID, &s.u.DisplayName, &s.status, &s.u.IsBotAdmin,
|
||||
&s.u.NotificationsEnabled, &s.reason, &s.u.Timezone, &s.u.CreatedAt, &s.u.UpdatedAt}
|
||||
}
|
||||
|
||||
func (s *userScan) result() domain.User {
|
||||
u := s.u
|
||||
u.Status = domain.UserStatus(s.status)
|
||||
if s.reason != nil {
|
||||
r := domain.PauseReason(*s.reason)
|
||||
u.PausedReason = &r
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func readUser(row scanner) (domain.User, error) {
|
||||
var s userScan
|
||||
if err := row.Scan(s.dest()...); err != nil {
|
||||
return domain.User{}, mapErr(err)
|
||||
}
|
||||
return s.result(), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id int64) (domain.User, error) {
|
||||
return readUser(s.pool.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByChatID(ctx context.Context, chatID int64) (domain.User, error) {
|
||||
return readUser(s.pool.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE telegram_chat_id = $1`, chatID))
|
||||
}
|
||||
|
||||
// CreatePendingUser cria o cadastro (idempotente por chat_id) e a jornada padrão.
|
||||
func (s *Store) CreatePendingUser(ctx context.Context, chatID int64, name, timezone string) (domain.User, bool, error) {
|
||||
var user domain.User
|
||||
created := false
|
||||
err := s.inTx(ctx, func(q querier) error {
|
||||
u, err := readUser(q.QueryRow(ctx, `
|
||||
INSERT INTO users (telegram_chat_id, display_name, timezone)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (telegram_chat_id) DO NOTHING
|
||||
RETURNING `+userCols(""), chatID, name, timezone))
|
||||
if err == domain.ErrNotFound {
|
||||
user, err = readUser(q.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE telegram_chat_id = $1`, chatID))
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.Exec(ctx, `INSERT INTO work_schedules (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
user, created = u, true
|
||||
return nil
|
||||
})
|
||||
return user, created, err
|
||||
}
|
||||
|
||||
// UserSummary é a linha da lista de usuários do painel.
|
||||
type UserSummary struct {
|
||||
User domain.User
|
||||
HasCredential bool
|
||||
CredentialValidated bool
|
||||
ConsecutiveFailures int
|
||||
CustomWindow bool
|
||||
}
|
||||
|
||||
type UserFilter struct {
|
||||
Status *string
|
||||
Query *string
|
||||
CustomWindow *bool
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context, f UserFilter) ([]UserSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+userCols("u.")+`,
|
||||
c.user_id IS NOT NULL,
|
||||
c.last_validated_at IS NOT NULL,
|
||||
COALESCE(c.consecutive_failures, 0),
|
||||
ws.query_window_start IS NOT NULL
|
||||
FROM users u
|
||||
LEFT JOIN point_credentials c ON c.user_id = u.id
|
||||
LEFT JOIN work_schedules ws ON ws.user_id = u.id
|
||||
WHERE ($1::text IS NULL OR u.status::text = $1)
|
||||
AND ($2::text IS NULL OR u.display_name ILIKE '%' || replace(replace(replace($2, '\', '\\'), '%', '\%'), '_', '\_') || '%'
|
||||
OR u.telegram_chat_id::text = $2)
|
||||
AND ($3::boolean IS NULL OR (ws.query_window_start IS NOT NULL) = $3)
|
||||
ORDER BY u.status, u.display_name`, f.Status, f.Query, f.CustomWindow)
|
||||
return collect(rows, err, func(row scanner) (UserSummary, error) {
|
||||
var us userScan
|
||||
var sum UserSummary
|
||||
dest := append(us.dest(), &sum.HasCredential, &sum.CredentialValidated, &sum.ConsecutiveFailures, &sum.CustomWindow)
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
sum.User = us.result()
|
||||
return sum, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetUserStatus(ctx context.Context, id int64, status domain.UserStatus) error {
|
||||
return expectOne(s.pool.Exec(ctx, `UPDATE users SET status = $2::text::user_status WHERE id = $1`, id, string(status)))
|
||||
}
|
||||
|
||||
// SetNotifications liga ou desliga os lembretes; reason é nil ao ligar.
|
||||
func (s *Store) SetNotifications(ctx context.Context, id int64, enabled bool, reason *domain.PauseReason) error {
|
||||
return expectOne(s.pool.Exec(ctx,
|
||||
`UPDATE users SET notifications_enabled = $2, notifications_paused_reason = $3 WHERE id = $1`,
|
||||
id, enabled, pauseReasonPtr(reason)))
|
||||
}
|
||||
|
||||
func (s *Store) SetBotAdmin(ctx context.Context, id int64, enabled bool) error {
|
||||
return expectOne(s.pool.Exec(ctx, `UPDATE users SET is_bot_admin = $2 WHERE id = $1`, id, enabled))
|
||||
}
|
||||
|
||||
func (s *Store) SetTimezone(ctx context.Context, id int64, timezone string) error {
|
||||
return expectOne(s.pool.Exec(ctx, `UPDATE users SET timezone = $2 WHERE id = $1`, id, timezone))
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveBotAdmins(ctx context.Context) ([]domain.User, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+userCols("")+` FROM users WHERE status = 'active' AND is_bot_admin ORDER BY id`)
|
||||
return collect(rows, err, readUser)
|
||||
}
|
||||
|
||||
// ReconUser é um usuário apto à reconciliação: ativo, com lembretes ligados e credencial validada.
|
||||
type ReconUser struct {
|
||||
User domain.User
|
||||
Schedule domain.WorkSchedule
|
||||
Credential domain.Credential
|
||||
}
|
||||
|
||||
func (s *Store) ListReconciliationUsers(ctx context.Context) ([]ReconUser, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+userCols("u.")+`, `+scheduleCols("ws.")+`, `+credentialCols("c.")+`
|
||||
FROM users u
|
||||
JOIN work_schedules ws ON ws.user_id = u.id
|
||||
JOIN point_credentials c ON c.user_id = u.id
|
||||
WHERE u.status = 'active' AND u.notifications_enabled AND c.last_validated_at IS NOT NULL
|
||||
ORDER BY u.id`)
|
||||
return collect(rows, err, func(row scanner) (ReconUser, error) {
|
||||
var us userScan
|
||||
var ss scheduleScan
|
||||
var cs credentialScan
|
||||
dest := append(append(us.dest(), ss.dest()...), cs.dest()...)
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return ReconUser{}, err
|
||||
}
|
||||
schedule, err := ss.result()
|
||||
if err != nil {
|
||||
return ReconUser{}, err
|
||||
}
|
||||
return ReconUser{User: us.result(), Schedule: schedule, Credential: cs.result()}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UserWithSchedule é usado na verificação de jornadas na subida do worker.
|
||||
type UserWithSchedule struct {
|
||||
User domain.User
|
||||
Schedule domain.WorkSchedule
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveSchedules(ctx context.Context) ([]UserWithSchedule, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+userCols("u.")+`, `+scheduleCols("ws.")+`
|
||||
FROM users u JOIN work_schedules ws ON ws.user_id = u.id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY u.id`)
|
||||
return collect(rows, err, func(row scanner) (UserWithSchedule, error) {
|
||||
var us userScan
|
||||
var ss scheduleScan
|
||||
if err := row.Scan(append(us.dest(), ss.dest()...)...); err != nil {
|
||||
return UserWithSchedule{}, err
|
||||
}
|
||||
schedule, err := ss.result()
|
||||
if err != nil {
|
||||
return UserWithSchedule{}, err
|
||||
}
|
||||
return UserWithSchedule{User: us.result(), Schedule: schedule}, nil
|
||||
})
|
||||
}
|
||||
157
backend/internal/adapters/postgres/web.go
Normal file
157
backend/internal/adapters/postgres/web.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
func accountCols(p string) string {
|
||||
return p + "id, " + p + "username::text, " + cols(p, "password_hash", "role", "user_id", "failed_login_count",
|
||||
"locked_until", "disabled_at", "last_login_at", "created_at")
|
||||
}
|
||||
|
||||
type accountScan struct {
|
||||
a domain.WebAccount
|
||||
role string
|
||||
}
|
||||
|
||||
func (s *accountScan) dest() []any {
|
||||
return []any{&s.a.ID, &s.a.Username, &s.a.PasswordHash, &s.role, &s.a.UserID, &s.a.FailedLoginCount,
|
||||
&s.a.LockedUntil, &s.a.DisabledAt, &s.a.LastLoginAt, &s.a.CreatedAt}
|
||||
}
|
||||
|
||||
func (s *accountScan) result() domain.WebAccount {
|
||||
a := s.a
|
||||
a.Role = domain.Role(s.role)
|
||||
return a
|
||||
}
|
||||
|
||||
func readAccount(row scanner) (domain.WebAccount, error) {
|
||||
var as accountScan
|
||||
if err := row.Scan(as.dest()...); err != nil {
|
||||
return domain.WebAccount{}, mapErr(err)
|
||||
}
|
||||
return as.result(), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateWebAccount(ctx context.Context, username, passwordHash string, role domain.Role, userID *int64) (domain.WebAccount, error) {
|
||||
return readAccount(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO web_accounts (username, password_hash, role, user_id)
|
||||
VALUES ($1::text::citext, $2, $3::text::web_role, $4)
|
||||
RETURNING `+accountCols(""), username, passwordHash, string(role), userID))
|
||||
}
|
||||
|
||||
func (s *Store) GetWebAccount(ctx context.Context, id int64) (domain.WebAccount, error) {
|
||||
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (s *Store) GetWebAccountByUsername(ctx context.Context, username string) (domain.WebAccount, error) {
|
||||
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE username = $1::text::citext`, username))
|
||||
}
|
||||
|
||||
func (s *Store) ListWebAccounts(ctx context.Context) ([]domain.WebAccount, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+accountCols("")+` FROM web_accounts ORDER BY username`)
|
||||
return collect(rows, err, readAccount)
|
||||
}
|
||||
|
||||
// UpdateWebAccount altera papel, situação e/ou vínculo com o usuário do Telegram;
|
||||
// nil mantém o valor atual. Em setUser, userID nil remove o vínculo.
|
||||
func (s *Store) UpdateWebAccount(ctx context.Context, id int64, role *domain.Role, disabled *bool, setUser bool, userID *int64) error {
|
||||
var roleText *string
|
||||
if role != nil {
|
||||
v := string(*role)
|
||||
roleText = &v
|
||||
}
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE web_accounts SET
|
||||
role = COALESCE($2::text::web_role, role),
|
||||
disabled_at = CASE
|
||||
WHEN $3::boolean IS NULL THEN disabled_at
|
||||
WHEN $3::boolean THEN COALESCE(disabled_at, now())
|
||||
ELSE NULL
|
||||
END,
|
||||
user_id = CASE WHEN $4::boolean THEN $5::bigint ELSE user_id END
|
||||
WHERE id = $1`, id, roleText, disabled, setUser, userID))
|
||||
}
|
||||
|
||||
func (s *Store) SetWebAccountPassword(ctx context.Context, id int64, passwordHash string) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE web_accounts SET password_hash = $2, failed_login_count = 0, locked_until = NULL
|
||||
WHERE id = $1`, id, passwordHash))
|
||||
}
|
||||
|
||||
func (s *Store) CountActiveOwners(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecordLoginFailure soma uma falha; ao atingir o limite, bloqueia até lockUntil e zera o contador.
|
||||
func (s *Store) RecordLoginFailure(ctx context.Context, id int64, maxFailures int, lockUntil time.Time) (locked bool, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE web_accounts SET
|
||||
failed_login_count = CASE WHEN failed_login_count + 1 >= $2 THEN 0 ELSE failed_login_count + 1 END,
|
||||
locked_until = CASE WHEN failed_login_count + 1 >= $2 THEN $3 ELSE locked_until END
|
||||
WHERE id = $1
|
||||
RETURNING locked_until IS NOT NULL AND locked_until > now()`, id, maxFailures, lockUntil).Scan(&locked)
|
||||
return locked, mapErr(err)
|
||||
}
|
||||
|
||||
func (s *Store) RecordLoginSuccess(ctx context.Context, id int64, at time.Time) error {
|
||||
return expectOne(s.pool.Exec(ctx, `
|
||||
UPDATE web_accounts SET failed_login_count = 0, locked_until = NULL, last_login_at = $2 WHERE id = $1`, id, at))
|
||||
}
|
||||
|
||||
type NewSession struct {
|
||||
AccountID int64
|
||||
TokenHash []byte
|
||||
CSRFTokenHash []byte
|
||||
ExpiresAt time.Time
|
||||
UserAgent *string
|
||||
IP *string
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, n NewSession) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO web_sessions (account_id, token_hash, csrf_token_hash, expires_at, last_seen_at, user_agent, ip)
|
||||
VALUES ($1, $2, $3, $4, now(), $5, $6::text::inet)`,
|
||||
n.AccountID, n.TokenHash, n.CSRFTokenHash, n.ExpiresAt, n.UserAgent, n.IP)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (domain.WebSession, error) {
|
||||
var ws domain.WebSession
|
||||
var as accountScan
|
||||
dest := append([]any{&ws.ID, &ws.AccountID, &ws.CSRFTokenHash, &ws.ExpiresAt, &ws.LastSeenAt, &ws.RevokedAt}, as.dest()...)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT s.id, s.account_id, s.csrf_token_hash, s.expires_at, s.last_seen_at, s.revoked_at, `+accountCols("a.")+`
|
||||
FROM web_sessions s JOIN web_accounts a ON a.id = s.account_id
|
||||
WHERE s.token_hash = $1`, tokenHash).Scan(dest...)
|
||||
if err != nil {
|
||||
return ws, mapErr(err)
|
||||
}
|
||||
ws.Account = as.result()
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (s *Store) TouchSession(ctx context.Context, id int64, at time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET last_seen_at = $2 WHERE id = $1`, id, at)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeSession(ctx context.Context, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAccountSessions(ctx context.Context, accountID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE account_id = $1 AND revoked_at IS NULL`, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSessionsEndedBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM web_sessions WHERE COALESCE(revoked_at, expires_at) < $1`, before)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
165
backend/internal/adapters/telegram/client.go
Normal file
165
backend/internal/adapters/telegram/client.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Package telegram é um cliente mínimo da Bot API (long polling e envio de
|
||||
// mensagens), portado do Modelo. Não usa bibliotecas externas.
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.telegram.org"
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
http *http.Client
|
||||
base string
|
||||
}
|
||||
|
||||
// NewClient aceita token vazio: nesse caso Send devolve domain.ErrNotConfigured.
|
||||
func NewClient(token string) *Client {
|
||||
return &Client{token: strings.TrimSpace(token), http: &http.Client{Timeout: 40 * time.Second}, base: apiBase}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool { return c.token != "" }
|
||||
|
||||
type Update struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *Message `json:"message"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Chat Chat `json:"chat"`
|
||||
From *From `json:"from"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type From struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Name é o nome de exibição usado no cadastro.
|
||||
func (f *From) Name() string {
|
||||
if f == nil {
|
||||
return "Sem nome"
|
||||
}
|
||||
name := strings.TrimSpace(f.FirstName + " " + f.LastName)
|
||||
if name == "" {
|
||||
name = f.Username
|
||||
}
|
||||
if name == "" {
|
||||
name = "Sem nome"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
type response[T any] struct {
|
||||
OK bool `json:"ok"`
|
||||
Result T `json:"result"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (r *response[T]) status() (bool, string) { return r.OK, r.Description }
|
||||
|
||||
type sendResult struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
|
||||
// apiResponse é satisfeita por *response[T] de qualquer tipo.
|
||||
type apiResponse interface {
|
||||
status() (ok bool, description string)
|
||||
}
|
||||
|
||||
// GetUpdates faz long polling por até timeout segundos.
|
||||
func (c *Client) GetUpdates(ctx context.Context, offset int64, timeout int) ([]Update, error) {
|
||||
if !c.Configured() {
|
||||
return nil, domain.ErrNotConfigured
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("timeout", strconv.Itoa(timeout))
|
||||
query.Set("offset", strconv.FormatInt(offset, 10))
|
||||
query.Set("allowed_updates", `["message"]`)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("getUpdates")+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, c.redact(err)
|
||||
}
|
||||
var body response[[]Update]
|
||||
if err := c.do(req, &body); err != nil {
|
||||
return nil, fmt.Errorf("telegram getUpdates: %w", err)
|
||||
}
|
||||
return body.Result, nil
|
||||
}
|
||||
|
||||
// Send envia uma mensagem de texto e devolve o message_id.
|
||||
func (c *Client) Send(ctx context.Context, chatID int64, text string) (int64, error) {
|
||||
if !c.Configured() {
|
||||
return 0, domain.ErrNotConfigured
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("chat_id", strconv.FormatInt(chatID, 10))
|
||||
form.Set("text", text)
|
||||
form.Set("disable_web_page_preview", "true")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("sendMessage"), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return 0, c.redact(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
var body response[sendResult]
|
||||
if err := c.do(req, &body); err != nil {
|
||||
return 0, fmt.Errorf("telegram sendMessage: %w", err)
|
||||
}
|
||||
return body.Result.MessageID, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out apiResponse) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return c.redact(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("HTTP %d com resposta ilegível", resp.StatusCode)
|
||||
}
|
||||
if ok, description := out.status(); !ok {
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return errors.New(description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) endpoint(method string) string {
|
||||
return c.base + "/bot" + c.token + "/" + method
|
||||
}
|
||||
|
||||
// redact evita que o token (que faz parte da URL) apareça em logs e auditoria.
|
||||
func (c *Client) redact(err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
if c.token == "" {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.ReplaceAll(err.Error(), c.token, "<token>"))
|
||||
}
|
||||
Reference in New Issue
Block a user