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