199 lines
6.7 KiB
Go
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
|
|
})
|
|
}
|