Files
bot-pontos/backend/internal/adapters/postgres/users.go
2026-10-23 22:31:02 -03:00

199 lines
6.7 KiB
Go

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