Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View File

@@ -0,0 +1,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
}

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

View 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
}

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

View 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
}

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

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

View 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
}

View 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
}

View 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
}

View 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
}

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

View 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
}