refactor(server/db): delegate users, sessions, profile to dbgen (D2)

Convert the auth_queries.go user + session reads/writes and
profile_queries.go to the sqlc-generated layer, adding shared
userFromGen/sessionFromGen mappers (db/mappers.go) for the
int64/*string -> int/bool/string domain-model narrowing.

Delegated: GetUserByID, GetUserByUsername, UpdateUserStatus,
UpdateUserTOTPSecret, ResetAllUserStatuses, BanUser, UnbanUser,
ListMembers, CreateSession (EvictOldestSessions + InsertSession),
GetSessionByTokenHash, GetSessionWithBanStatus, DeleteSession,
DeleteOtherSessions, DeleteExpiredSessions, TouchSession,
UpdateUserProfile, UpdateUserPassword, ListUserSessions,
DeleteSessionByID (query changed to :execresult so the RowsAffected
ErrNotFound check is preserved). Behavior and public signatures unchanged.

Verified: go build ./...; go test ./db ./service ./auth; sqlc-verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 15:34:27 +00:00
parent 44323373e3
commit e46bd0e015
6 changed files with 156 additions and 176 deletions
+81 -132
View File
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"time"
"github.com/owncord/server/db/dbgen"
)
// ─── User Operations ──────────────────────────────────────────────────────────
@@ -119,53 +121,34 @@ func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inv
// GetUserByUsername returns the user with the given username (case-insensitive),
// or nil if not found.
func (d *DB) GetUserByUsername(username string) (*User, error) {
row := d.sqlDB.QueryRow(
`SELECT id, username, password, avatar, role_id, totp_secret, status,
created_at, last_seen, banned, ban_reason, ban_expires
FROM users WHERE username = ? COLLATE NOCASE`,
username,
)
return scanUser(row)
}
// GetUserByID returns the user with the given ID, or nil if not found.
func (d *DB) GetUserByID(id int64) (*User, error) {
row := d.sqlDB.QueryRow(
`SELECT id, username, password, avatar, role_id, totp_secret, status,
created_at, last_seen, banned, ban_reason, ban_expires
FROM users WHERE id = ?`,
id,
)
return scanUser(row)
}
// scanUser reads a User from a *sql.Row, returning nil (not an error) when the
// row is not found.
func scanUser(row *sql.Row) (*User, error) {
u := &User{}
var banned int
err := row.Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID,
&u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen,
&banned, &u.BanReason, &u.BanExpires,
)
u, err := d.q.GetUserByUsername(dbCtx(), username)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanUser: %w", err)
return nil, fmt.Errorf("GetUserByUsername: %w", err)
}
u.Banned = banned != 0
return u, nil
return userFromGen(u), nil
}
// GetUserByID returns the user with the given ID, or nil if not found.
func (d *DB) GetUserByID(id int64) (*User, error) {
u, err := d.q.GetUserByID(dbCtx(), id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetUserByID: %w", err)
}
return userFromGen(u), nil
}
// UpdateUserStatus sets the status column for the given user ID.
func (d *DB) UpdateUserStatus(id int64, status string) error {
_, err := d.sqlDB.Exec(
`UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?`,
status, id,
)
if err != nil {
if err := d.q.UpdateUserStatus(dbCtx(), dbgen.UpdateUserStatusParams{
Status: status,
ID: id,
}); err != nil {
return fmt.Errorf("UpdateUserStatus: %w", err)
}
return nil
@@ -173,8 +156,10 @@ func (d *DB) UpdateUserStatus(id int64, status string) error {
// UpdateUserTOTPSecret sets or clears the TOTP secret for a user.
func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error {
_, err := d.sqlDB.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, id)
if err != nil {
if err := d.q.UpdateUserTOTPSecret(dbCtx(), dbgen.UpdateUserTOTPSecretParams{
TotpSecret: secret,
ID: id,
}); err != nil {
return fmt.Errorf("UpdateUserTOTPSecret: %w", err)
}
return nil
@@ -183,8 +168,7 @@ func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error {
// ResetAllUserStatuses sets all users to "offline". Called on server startup
// to clear stale statuses from a previous run or crash.
func (d *DB) ResetAllUserStatuses() error {
_, err := d.sqlDB.Exec(`UPDATE users SET status = 'offline' WHERE status != 'offline'`)
if err != nil {
if err := d.q.ResetAllUserStatuses(dbCtx()); err != nil {
return fmt.Errorf("ResetAllUserStatuses: %w", err)
}
return nil
@@ -198,11 +182,12 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error {
s := expires.UTC().Format("2006-01-02T15:04:05Z")
expiresStr = &s
}
_, err := d.sqlDB.Exec(
`UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`,
reason, expiresStr, id,
)
if err != nil {
reasonCopy := reason
if err := d.q.BanUser(dbCtx(), dbgen.BanUserParams{
BanReason: &reasonCopy,
BanExpires: expiresStr,
ID: id,
}); err != nil {
return fmt.Errorf("BanUser: %w", err)
}
return nil
@@ -210,11 +195,7 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error {
// UnbanUser removes the ban from a user.
func (d *DB) UnbanUser(id int64) error {
_, err := d.sqlDB.Exec(
`UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`,
id,
)
if err != nil {
if err := d.q.UnbanUser(dbCtx(), id); err != nil {
return fmt.Errorf("UnbanUser: %w", err)
}
return nil
@@ -233,21 +214,20 @@ const maxSessionsPerUser = 25
// the limit is reached.
func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) {
// Evict oldest sessions if at or above the cap.
_, _ = d.sqlDB.Exec(
`DELETE FROM sessions WHERE id IN (
SELECT id FROM sessions WHERE user_id = ?
ORDER BY created_at DESC
LIMIT -1 OFFSET ?
)`,
userID, maxSessionsPerUser-1,
)
_ = d.q.EvictOldestSessions(dbCtx(), dbgen.EvictOldestSessionsParams{
UserID: userID,
Offset: maxSessionsPerUser - 1,
})
expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z")
res, err := d.sqlDB.Exec(
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
VALUES (?, ?, ?, ?, ?)`,
userID, tokenHash, device, ip, expiresAt,
)
deviceCopy, ipCopy := device, ip
res, err := d.q.InsertSession(dbCtx(), dbgen.InsertSessionParams{
UserID: userID,
Token: tokenHash,
Device: &deviceCopy,
IpAddress: &ipCopy,
ExpiresAt: expiresAt,
})
if err != nil {
return 0, fmt.Errorf("CreateSession: %w", err)
}
@@ -257,23 +237,15 @@ func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, e
// GetSessionByTokenHash retrieves a session by its hashed token, or nil if
// not found.
func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) {
row := d.sqlDB.QueryRow(
`SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at
FROM sessions WHERE token = ?`,
tokenHash,
)
s := &Session{}
err := row.Scan(
&s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP,
&s.CreatedAt, &s.LastUsed, &s.ExpiresAt,
)
s, err := d.q.GetSessionByTokenHash(dbCtx(), tokenHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetSessionByTokenHash: %w", err)
}
return s, nil
sess := sessionFromGen(s)
return &sess, nil
}
// SessionWithBanStatus combines session data with user ban fields
@@ -288,36 +260,33 @@ type SessionWithBanStatus struct {
// GetSessionWithBanStatus returns the session joined with the user's ban
// status in a single query. Returns nil, nil when not found.
func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) {
row := d.sqlDB.QueryRow(
`SELECT s.id, s.user_id, s.token, s.device, s.ip_address,
s.created_at, s.last_used, s.expires_at,
u.banned, u.ban_reason, u.ban_expires
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?`,
tokenHash,
)
r := &SessionWithBanStatus{}
var banned int
err := row.Scan(
&r.ID, &r.UserID, &r.TokenHash, &r.Device, &r.IP,
&r.CreatedAt, &r.LastUsed, &r.ExpiresAt,
&banned, &r.BanReason, &r.BanExpires,
)
row, err := d.q.GetSessionWithBanStatus(dbCtx(), tokenHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetSessionWithBanStatus: %w", err)
}
r.Banned = banned != 0
return r, nil
return &SessionWithBanStatus{
Session: Session{
ID: row.ID,
UserID: row.UserID,
TokenHash: row.Token,
Device: derefString(row.Device),
IP: derefString(row.IpAddress),
CreatedAt: row.CreatedAt,
LastUsed: row.LastUsed,
ExpiresAt: row.ExpiresAt,
},
Banned: row.Banned != 0,
BanReason: row.BanReason,
BanExpires: row.BanExpires,
}, nil
}
// DeleteSession removes the session with the given token hash.
func (d *DB) DeleteSession(tokenHash string) error {
_, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE token = ?`, tokenHash)
if err != nil {
if err := d.q.DeleteSessionByToken(dbCtx(), tokenHash); err != nil {
return fmt.Errorf("DeleteSession: %w", err)
}
return nil
@@ -327,10 +296,10 @@ func (d *DB) DeleteSession(tokenHash string) error {
// with keepSessionID. Used after password change or 2FA state change to
// invalidate all other sessions (BUG-108).
func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) {
result, err := d.sqlDB.Exec(
`DELETE FROM sessions WHERE user_id = ? AND id != ?`,
userID, keepSessionID,
)
result, err := d.q.DeleteOtherSessions(dbCtx(), dbgen.DeleteOtherSessionsParams{
UserID: userID,
ID: keepSessionID,
})
if err != nil {
return 0, fmt.Errorf("DeleteOtherSessions: %w", err)
}
@@ -341,10 +310,7 @@ func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) {
// DeleteExpiredSessions removes all sessions whose expires_at is in the past.
// Compares using strftime to handle both ISO-8601 and SQLite datetime formats.
func (d *DB) DeleteExpiredSessions() error {
_, err := d.sqlDB.Exec(
`DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now')`,
)
if err != nil {
if err := d.q.DeleteExpiredSessions(dbCtx()); err != nil {
return fmt.Errorf("DeleteExpiredSessions: %w", err)
}
return nil
@@ -352,11 +318,7 @@ func (d *DB) DeleteExpiredSessions() error {
// TouchSession updates last_used for the session with the given token hash.
func (d *DB) TouchSession(tokenHash string) error {
_, err := d.sqlDB.Exec(
`UPDATE sessions SET last_used = datetime('now') WHERE token = ?`,
tokenHash,
)
if err != nil {
if err := d.q.TouchSession(dbCtx(), tokenHash); err != nil {
return fmt.Errorf("TouchSession: %w", err)
}
return nil
@@ -471,32 +433,19 @@ type MemberSummary struct {
// ListMembers returns non-banned users as lightweight summaries.
// M-12: Limited to 1000 rows to prevent unbounded result sets on large servers.
func (d *DB) ListMembers() ([]MemberSummary, error) {
rows, err := d.sqlDB.Query(
`SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name)
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.banned = 0
ORDER BY u.username ASC
LIMIT 1000`,
)
rows, err := d.q.ListMembers(dbCtx())
if err != nil {
return nil, fmt.Errorf("ListMembers: %w", err)
}
defer rows.Close() //nolint:errcheck
var members []MemberSummary
for rows.Next() {
var m MemberSummary
if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.Role); err != nil {
return nil, fmt.Errorf("ListMembers scan: %w", err)
}
members = append(members, m)
}
if rows.Err() != nil {
return nil, fmt.Errorf("ListMembers rows: %w", rows.Err())
}
if members == nil {
members = []MemberSummary{}
members := make([]MemberSummary, 0, len(rows))
for _, r := range rows {
members = append(members, MemberSummary{
ID: r.ID,
Username: r.Username,
Avatar: r.Avatar,
Status: r.Status,
Role: r.Lower,
})
}
return members, nil
}
+1 -1
View File
@@ -38,7 +38,7 @@ type Querier interface {
DeleteLockout(ctx context.Context, key string) error
DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error)
DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (sql.Result, error)
DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error
DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error)
DeleteSessionByToken(ctx context.Context, token string) error
DisablePlugin(ctx context.Context, id int64) error
EditMessageContent(ctx context.Context, arg EditMessageContentParams) error
+3 -4
View File
@@ -32,7 +32,7 @@ func (q *Queries) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessio
return q.db.ExecContext(ctx, deleteOtherSessions, arg.UserID, arg.ID)
}
const deleteSessionByID = `-- name: DeleteSessionByID :exec
const deleteSessionByID = `-- name: DeleteSessionByID :execresult
DELETE FROM sessions WHERE id = ? AND user_id = ?
`
@@ -41,9 +41,8 @@ type DeleteSessionByIDParams struct {
UserID int64 `json:"userId"`
}
func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error {
_, err := q.db.ExecContext(ctx, deleteSessionByID, arg.ID, arg.UserID)
return err
func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error) {
return q.db.ExecContext(ctx, deleteSessionByID, arg.ID, arg.UserID)
}
const deleteSessionByToken = `-- name: DeleteSessionByToken :exec
+51
View File
@@ -0,0 +1,51 @@
package db
import "github.com/owncord/server/db/dbgen"
// This file holds the conversions from sqlc-generated row/model types
// (db/dbgen) to the domain model types this package exposes. sqlc emits int64
// for integer columns and *string for nullable text; the domain models use
// narrower Go types (int, bool, non-pointer string) for ergonomics, so each
// delegating read maps through one of these helpers. See docs/plans/sqlc-adoption.md.
// derefString returns the pointed-to string, or "" when the pointer is nil.
// Used for columns the schema allows to be NULL but the domain model exposes
// as a plain string (device, ip_address).
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// userFromGen maps a generated user row to the domain User model.
func userFromGen(u dbgen.User) *User {
return &User{
ID: u.ID,
Username: u.Username,
PasswordHash: u.Password,
Avatar: u.Avatar,
RoleID: u.RoleID,
TOTPSecret: u.TotpSecret,
Status: u.Status,
CreatedAt: u.CreatedAt,
LastSeen: u.LastSeen,
Banned: u.Banned != 0,
BanReason: u.BanReason,
BanExpires: u.BanExpires,
}
}
// sessionFromGen maps a generated session row to the domain Session model.
func sessionFromGen(s dbgen.Session) Session {
return Session{
ID: s.ID,
UserID: s.UserID,
TokenHash: s.Token,
Device: derefString(s.Device),
IP: derefString(s.IpAddress),
CreatedAt: s.CreatedAt,
LastUsed: s.LastUsed,
ExpiresAt: s.ExpiresAt,
}
}
+19 -38
View File
@@ -2,16 +2,19 @@ package db
import (
"fmt"
"github.com/owncord/server/db/dbgen"
)
// UpdateUserProfile updates the username and avatar for the given user.
// Returns ErrNotFound if the user does not exist. Returns an error wrapping
// a UNIQUE constraint violation if the username is already taken.
func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) error {
result, err := d.sqlDB.Exec(
`UPDATE users SET username = ?, avatar = ? WHERE id = ?`,
username, avatar, userID,
)
result, err := d.q.UpdateUserProfile(dbCtx(), dbgen.UpdateUserProfileParams{
Username: username,
Avatar: avatar,
ID: userID,
})
if err != nil {
return fmt.Errorf("UpdateUserProfile: %w", err)
}
@@ -27,11 +30,10 @@ func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) er
// UpdateUserPassword sets a new password hash for the given user.
func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error {
_, err := d.sqlDB.Exec(
`UPDATE users SET password = ? WHERE id = ?`,
newPasswordHash, userID,
)
if err != nil {
if err := d.q.UpdateUserPassword(dbCtx(), dbgen.UpdateUserPasswordParams{
Password: newPasswordHash,
ID: userID,
}); err != nil {
return fmt.Errorf("UpdateUserPassword: %w", err)
}
return nil
@@ -40,34 +42,13 @@ func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error {
// ListUserSessions returns all sessions for the given user in a single query.
// Results are ordered by created_at descending (newest first).
func (d *DB) ListUserSessions(userID int64) ([]Session, error) {
rows, err := d.sqlDB.Query(
`SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at
FROM sessions
WHERE user_id = ?
ORDER BY created_at DESC`,
userID,
)
rows, err := d.q.ListUserSessions(dbCtx(), userID)
if err != nil {
return nil, fmt.Errorf("ListUserSessions: %w", err)
}
defer rows.Close() //nolint:errcheck
var sessions []Session
for rows.Next() {
var s Session
if err := rows.Scan(
&s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP,
&s.CreatedAt, &s.LastUsed, &s.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("ListUserSessions scan: %w", err)
}
sessions = append(sessions, s)
}
if rows.Err() != nil {
return nil, fmt.Errorf("ListUserSessions rows: %w", rows.Err())
}
if sessions == nil {
sessions = []Session{}
sessions := make([]Session, 0, len(rows))
for _, s := range rows {
sessions = append(sessions, sessionFromGen(s))
}
return sessions, nil
}
@@ -76,10 +57,10 @@ func (d *DB) ListUserSessions(userID int64) ([]Session, error) {
// the specified user. Returns ErrNotFound if the session does not exist or
// does not belong to the user.
func (d *DB) DeleteSessionByID(sessionID, userID int64) error {
result, err := d.sqlDB.Exec(
`DELETE FROM sessions WHERE id = ? AND user_id = ?`,
sessionID, userID,
)
result, err := d.q.DeleteSessionByID(dbCtx(), dbgen.DeleteSessionByIDParams{
ID: sessionID,
UserID: userID,
})
if err != nil {
return fmt.Errorf("DeleteSessionByID: %w", err)
}
+1 -1
View File
@@ -24,7 +24,7 @@ WHERE s.token = ?;
-- name: DeleteSessionByToken :exec
DELETE FROM sessions WHERE token = ?;
-- name: DeleteSessionByID :exec
-- name: DeleteSessionByID :execresult
DELETE FROM sessions WHERE id = ? AND user_id = ?;
-- name: DeleteOtherSessions :execresult