Projeto bot pontos
This commit is contained in:
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user