diff --git a/internal/model/admin.go b/internal/model/admin.go index e40e5fd..fd27687 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -19,6 +19,11 @@ type AdminUser struct { BannedAt *time.Time `json:"banned_at"` PostCount int `json:"post_count"` CreatedAt time.Time `json:"created_at"` + + // Connected auth methods. HasPassword means email+password login is set up; + // Providers lists linked OAuth providers (e.g. "google", "facebook"). + HasPassword bool `json:"has_password"` + Providers []string `json:"providers"` } // AdminPost is a row in the admin posts table. diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 9d2b49b..272291d 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -37,6 +37,9 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) u.email_verified_at, u.banned_at, (SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count, u.created_at, + (u.password_hash IS NOT NULL) AS has_password, + (SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[]) + FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers, COUNT(*) OVER() AS total FROM users u WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%' @@ -53,7 +56,8 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) for rows.Next() { var u model.AdminUser if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL, - &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &total); err != nil { + &u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, + &u.HasPassword, &u.Providers, &total); err != nil { return nil, 0, fmt.Errorf("scan admin user: %w", err) } out = append(out, u) @@ -178,8 +182,9 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs return out, total, rows.Err() } -// SaveReply stores an admin reply on a feedback row, stamps replied_at, and -// resolves it (if not already). It returns the recipient's email (the +// SaveReply stores an admin reply on a feedback row and stamps replied_at. It +// does NOT change resolved_at — replying and resolving are independent actions +// (resolve via ResolveFeedback). It returns the recipient's email (the // submitter's account email, else the anonymous contact_email — may be nil if // neither is known) and name so the caller can send the reply email. ok is // false when the id doesn't exist. @@ -187,8 +192,7 @@ func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *s err = r.pg.Pool.QueryRow(ctx, ` UPDATE feedback f SET reply_message = $2, - replied_at = now(), - resolved_at = COALESCE(resolved_at, now()) + replied_at = now() WHERE f.id = $1 RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email), COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`, diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index a3961a4..d73ec96 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -88,9 +88,10 @@ func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body strin return s.notif.NotifyAdminMessage(ctx, userID, title, body) } -// ReplyFeedback stores the reply (resolving the row) and emails it to the -// submitter when an address is known. Returns found=false if the id is -// unknown, and emailed=true when a reply email was actually sent. +// ReplyFeedback stores the reply and emails it to the submitter when an +// address is known. It does not resolve the row (resolve is a separate +// action). Returns found=false if the id is unknown, and emailed=true when a +// reply email was actually sent. func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) { email, name, ok, err := s.repo.SaveReply(ctx, id, message) if err != nil || !ok {