// 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) }