tenseleyflow/shithub / a41bd07

Browse files

Add S10 sqlc queries: profile/avatar updates, rename + redirect-rate-limit count, theme + session_epoch, restore, primary-email + delete, notif prefs CRUD

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a41bd07157ac90b2144ed510a1e46c644d0b91ed
Parents
56eaf44
Tree
e774507

8 changed files

StatusFile+-
M internal/users/queries/user_emails.sql 15 0
A internal/users/queries/user_notification_prefs.sql 16 0
M internal/users/queries/users.sql 48 0
M internal/users/sqlc/models.go 9 0
M internal/users/sqlc/querier.go 28 0
M internal/users/sqlc/user_emails.sql.go 48 0
A internal/users/sqlc/user_notification_prefs.sql.go 76 0
M internal/users/sqlc/users.sql.go 209 3
internal/users/queries/user_emails.sqlmodified
@@ -43,3 +43,18 @@ SELECT id, user_id, email, is_primary, verified, verification_token_hash,
4343
        verification_sent_at, verified_at, created_at
4444
 FROM user_emails
4545
 WHERE verification_token_hash = $1;
46
+
47
+-- name: SetUserEmailPrimary :exec
48
+-- Atomically unset the existing primary and set the supplied row as
49
+-- primary. Caller MUST have already verified the row belongs to the
50
+-- user and is verified.
51
+UPDATE user_emails SET is_primary = (id = $2) WHERE user_id = $1;
52
+
53
+-- name: DeleteUserEmail :execrows
54
+-- Scoped delete: caller must pass owning user_id. Refuses to delete
55
+-- the primary email (UI must guide the user to set a different primary first).
56
+DELETE FROM user_emails
57
+WHERE id = $1 AND user_id = $2 AND is_primary = false;
58
+
59
+-- name: CountVerifiedUserEmails :one
60
+SELECT count(*) FROM user_emails WHERE user_id = $1 AND verified = true;
internal/users/queries/user_notification_prefs.sqladded
@@ -0,0 +1,16 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: ListUserNotificationPrefs :many
4
+SELECT user_id, key, value, updated_at
5
+FROM user_notification_prefs
6
+WHERE user_id = $1
7
+ORDER BY key;
8
+
9
+-- name: UpsertUserNotificationPref :exec
10
+INSERT INTO user_notification_prefs (user_id, key, value)
11
+VALUES ($1, $2, $3)
12
+ON CONFLICT (user_id, key) DO UPDATE
13
+SET value = EXCLUDED.value;
14
+
15
+-- name: DeleteUserNotificationPref :exec
16
+DELETE FROM user_notification_prefs WHERE user_id = $1 AND key = $2;
internal/users/queries/users.sqlmodified
@@ -54,3 +54,51 @@ WHERE id = $1;
5454
 
5555
 -- name: CountUsers :one
5656
 SELECT count(*) FROM users WHERE deleted_at IS NULL;
57
+
58
+-- name: UpdateUserProfile :exec
59
+UPDATE users
60
+SET display_name = $2,
61
+    bio          = $3,
62
+    location     = $4,
63
+    website      = $5,
64
+    company      = $6,
65
+    pronouns     = $7
66
+WHERE id = $1;
67
+
68
+-- name: UpdateUserAvatarKey :exec
69
+UPDATE users
70
+SET avatar_object_key = $2
71
+WHERE id = $1;
72
+
73
+-- name: RenameUser :exec
74
+-- Wrapped by the username-change flow inside a tx that also writes
75
+-- username_redirects, so the old name becomes a redirect target atomically.
76
+UPDATE users
77
+SET username = $2
78
+WHERE id = $1;
79
+
80
+-- name: CountRecentUsernameChanges :one
81
+-- Drives the 3-changes-per-60d cap.
82
+SELECT count(*) FROM username_redirects
83
+WHERE user_id = $1 AND changed_at > $2;
84
+
85
+-- name: UpdateUserTheme :exec
86
+UPDATE users SET theme = $2 WHERE id = $1;
87
+
88
+-- name: BumpUserSessionEpoch :exec
89
+UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1;
90
+
91
+-- name: GetUserSessionEpoch :one
92
+SELECT session_epoch FROM users WHERE id = $1;
93
+
94
+-- name: RestoreUserAccount :exec
95
+-- Clears deleted_at; called when a user logs in within the 14-day grace
96
+-- window. The login handler enforces the window check before calling.
97
+UPDATE users SET deleted_at = NULL WHERE id = $1;
98
+
99
+-- name: GetUserIncludingDeleted :one
100
+-- Like GetUserByID but returns the row even when deleted_at IS NOT NULL.
101
+SELECT * FROM users WHERE id = $1;
102
+
103
+-- name: GetUserByUsernameIncludingDeleted :one
104
+SELECT * FROM users WHERE username = $1;
internal/users/sqlc/models.gomodified
@@ -73,6 +73,8 @@ type User struct {
7373
 	Company           string
7474
 	Pronouns          string
7575
 	AvatarObjectKey   pgtype.Text
76
+	Theme             string
77
+	SessionEpoch      int32
7678
 }
7779
 
7880
 type UserEmail struct {
@@ -87,6 +89,13 @@ type UserEmail struct {
8789
 	CreatedAt             pgtype.Timestamptz
8890
 }
8991
 
92
+type UserNotificationPref struct {
93
+	UserID    int64
94
+	Key       string
95
+	Value     []byte
96
+	UpdatedAt pgtype.Timestamptz
97
+}
98
+
9099
 type UserRecoveryCode struct {
91100
 	ID          int64
92101
 	UserID      int64
internal/users/sqlc/querier.gomodified
@@ -20,6 +20,7 @@ type Querier interface {
2020
 	// strictly greater. Returns rows affected — 0 means a replay attempt and
2121
 	// the caller should reject the code.
2222
 	BumpTOTPCounter(ctx context.Context, db DBTX, arg BumpTOTPCounterParams) (int64, error)
23
+	BumpUserSessionEpoch(ctx context.Context, db DBTX, id int64) error
2324
 	// Sets confirmed_at on a pending row. Returns the number of rows updated;
2425
 	// callers MUST check this to handle the parallel-enrollment race
2526
 	// (only one of two concurrent confirms wins).
@@ -31,9 +32,12 @@ type Querier interface {
3132
 	// 0 means rejected.
3233
 	ConsumeRecoveryCode(ctx context.Context, db DBTX, arg ConsumeRecoveryCodeParams) (int64, error)
3334
 	CountActiveUserTokens(ctx context.Context, db DBTX, userID int64) (int64, error)
35
+	// Drives the 3-changes-per-60d cap.
36
+	CountRecentUsernameChanges(ctx context.Context, db DBTX, arg CountRecentUsernameChangesParams) (int64, error)
3437
 	CountUnusedRecoveryCodes(ctx context.Context, db DBTX, userID int64) (int64, error)
3538
 	CountUserSSHKeys(ctx context.Context, db DBTX, userID int64) (int64, error)
3639
 	CountUsers(ctx context.Context, db DBTX) (int64, error)
40
+	CountVerifiedUserEmails(ctx context.Context, db DBTX, userID int64) (int64, error)
3741
 	// SPDX-License-Identifier: AGPL-3.0-or-later
3842
 	CreateEmailVerification(ctx context.Context, db DBTX, arg CreateEmailVerificationParams) (EmailVerification, error)
3943
 	// SPDX-License-Identifier: AGPL-3.0-or-later
@@ -44,6 +48,10 @@ type Querier interface {
4448
 	CreateUserEmail(ctx context.Context, db DBTX, arg CreateUserEmailParams) (UserEmail, error)
4549
 	DeleteExpiredEmailVerifications(ctx context.Context, db DBTX) error
4650
 	DeleteExpiredPasswordResets(ctx context.Context, db DBTX) error
51
+	// Scoped delete: caller must pass owning user_id. Refuses to delete
52
+	// the primary email (UI must guide the user to set a different primary first).
53
+	DeleteUserEmail(ctx context.Context, db DBTX, arg DeleteUserEmailParams) (int64, error)
54
+	DeleteUserNotificationPref(ctx context.Context, db DBTX, arg DeleteUserNotificationPrefParams) error
4755
 	DeleteUserRecoveryCodes(ctx context.Context, db DBTX, userID int64) error
4856
 	// Scoped delete: caller must pass the owning user_id so a hijacked
4957
 	// handler can never delete keys it doesn't own.
@@ -53,12 +61,16 @@ type Querier interface {
5361
 	GetPasswordResetByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (PasswordReset, error)
5462
 	GetUserByID(ctx context.Context, db DBTX, id int64) (User, error)
5563
 	GetUserByUsername(ctx context.Context, db DBTX, username string) (User, error)
64
+	GetUserByUsernameIncludingDeleted(ctx context.Context, db DBTX, username string) (User, error)
5665
 	GetUserEmailByAddress(ctx context.Context, db DBTX, email string) (UserEmail, error)
5766
 	GetUserEmailByID(ctx context.Context, db DBTX, id int64) (UserEmail, error)
5867
 	GetUserEmailByVerificationHash(ctx context.Context, db DBTX, verificationTokenHash []byte) (UserEmail, error)
68
+	// Like GetUserByID but returns the row even when deleted_at IS NOT NULL.
69
+	GetUserIncludingDeleted(ctx context.Context, db DBTX, id int64) (User, error)
5970
 	// Hot path for sshd's AuthorizedKeysCommand. Index lookup via the UNIQUE
6071
 	// index on fingerprint_sha256.
6172
 	GetUserSSHKeyByFingerprint(ctx context.Context, db DBTX, fingerprintSha256 string) (UserSshKey, error)
73
+	GetUserSessionEpoch(ctx context.Context, db DBTX, id int64) (int32, error)
6274
 	GetUserTOTP(ctx context.Context, db DBTX, userID int64) (UserTotp, error)
6375
 	// Hot path for the auth middleware. token_hash is UNIQUE; returns at
6476
 	// most one row. Caller MUST also check revoked_at IS NULL and
@@ -81,6 +93,8 @@ type Querier interface {
8193
 	LinkUserPrimaryEmail(ctx context.Context, db DBTX, arg LinkUserPrimaryEmailParams) error
8294
 	ListAuditLogForTarget(ctx context.Context, db DBTX, arg ListAuditLogForTargetParams) ([]AuthAuditLog, error)
8395
 	ListUserEmailsForUser(ctx context.Context, db DBTX, userID int64) ([]UserEmail, error)
96
+	// SPDX-License-Identifier: AGPL-3.0-or-later
97
+	ListUserNotificationPrefs(ctx context.Context, db DBTX, userID int64) ([]UserNotificationPref, error)
8498
 	ListUserSSHKeys(ctx context.Context, db DBTX, userID int64) ([]UserSshKey, error)
8599
 	ListUserTokens(ctx context.Context, db DBTX, userID int64) ([]UserToken, error)
86100
 	// SPDX-License-Identifier: AGPL-3.0-or-later
@@ -92,19 +106,33 @@ type Querier interface {
92106
 	MarkUserEmailPrimaryVerified(ctx context.Context, db DBTX, id int64) error
93107
 	MarkUserEmailVerified(ctx context.Context, db DBTX, id int64) error
94108
 	PurgeStaleAuthThrottle(ctx context.Context, db DBTX, windowStartedAt pgtype.Timestamptz) error
109
+	// Wrapped by the username-change flow inside a tx that also writes
110
+	// username_redirects, so the old name becomes a redirect target atomically.
111
+	RenameUser(ctx context.Context, db DBTX, arg RenameUserParams) error
95112
 	ResetAuthThrottle(ctx context.Context, db DBTX, arg ResetAuthThrottleParams) error
113
+	// Clears deleted_at; called when a user logs in within the 14-day grace
114
+	// window. The login handler enforces the window check before calling.
115
+	RestoreUserAccount(ctx context.Context, db DBTX, id int64) error
96116
 	// Used by user suspension to revoke every active token in one statement.
97117
 	RevokeAllUserTokens(ctx context.Context, db DBTX, userID int64) error
98118
 	// Scoped revoke: caller must pass owning user_id so a hijacked handler
99119
 	// can never revoke tokens it doesn't own. No-op on already-revoked rows.
100120
 	RevokeUserToken(ctx context.Context, db DBTX, arg RevokeUserTokenParams) (int64, error)
121
+	// Atomically unset the existing primary and set the supplied row as
122
+	// primary. Caller MUST have already verified the row belongs to the
123
+	// user and is verified.
124
+	SetUserEmailPrimary(ctx context.Context, db DBTX, arg SetUserEmailPrimaryParams) error
101125
 	SetVerificationToken(ctx context.Context, db DBTX, arg SetVerificationTokenParams) error
102126
 	SoftDeleteUser(ctx context.Context, db DBTX, id int64) error
103127
 	SuspendUser(ctx context.Context, db DBTX, arg SuspendUserParams) error
104128
 	TouchSSHKeyLastUsed(ctx context.Context, db DBTX, arg TouchSSHKeyLastUsedParams) error
105129
 	TouchUserLastLogin(ctx context.Context, db DBTX, id int64) error
106130
 	TouchUserTokenLastUsed(ctx context.Context, db DBTX, arg TouchUserTokenLastUsedParams) error
131
+	UpdateUserAvatarKey(ctx context.Context, db DBTX, arg UpdateUserAvatarKeyParams) error
107132
 	UpdateUserPassword(ctx context.Context, db DBTX, arg UpdateUserPasswordParams) error
133
+	UpdateUserProfile(ctx context.Context, db DBTX, arg UpdateUserProfileParams) error
134
+	UpdateUserTheme(ctx context.Context, db DBTX, arg UpdateUserThemeParams) error
135
+	UpsertUserNotificationPref(ctx context.Context, db DBTX, arg UpsertUserNotificationPrefParams) error
108136
 	// SPDX-License-Identifier: AGPL-3.0-or-later
109137
 	// Inserts a new pending TOTP row, or replaces an existing pending row for
110138
 	// the same user. Confirmed rows are NOT replaced — disable+regenerate
internal/users/sqlc/user_emails.sql.gomodified
@@ -9,6 +9,17 @@ import (
99
 	"context"
1010
 )
1111
 
12
+const countVerifiedUserEmails = `-- name: CountVerifiedUserEmails :one
13
+SELECT count(*) FROM user_emails WHERE user_id = $1 AND verified = true
14
+`
15
+
16
+func (q *Queries) CountVerifiedUserEmails(ctx context.Context, db DBTX, userID int64) (int64, error) {
17
+	row := db.QueryRow(ctx, countVerifiedUserEmails, userID)
18
+	var count int64
19
+	err := row.Scan(&count)
20
+	return count, err
21
+}
22
+
1223
 const createUserEmail = `-- name: CreateUserEmail :one
1324
 
1425
 INSERT INTO user_emails (user_id, email, is_primary, verified, verification_token_hash, verification_sent_at)
@@ -49,6 +60,26 @@ func (q *Queries) CreateUserEmail(ctx context.Context, db DBTX, arg CreateUserEm
4960
 	return i, err
5061
 }
5162
 
63
+const deleteUserEmail = `-- name: DeleteUserEmail :execrows
64
+DELETE FROM user_emails
65
+WHERE id = $1 AND user_id = $2 AND is_primary = false
66
+`
67
+
68
+type DeleteUserEmailParams struct {
69
+	ID     int64
70
+	UserID int64
71
+}
72
+
73
+// Scoped delete: caller must pass owning user_id. Refuses to delete
74
+// the primary email (UI must guide the user to set a different primary first).
75
+func (q *Queries) DeleteUserEmail(ctx context.Context, db DBTX, arg DeleteUserEmailParams) (int64, error) {
76
+	result, err := db.Exec(ctx, deleteUserEmail, arg.ID, arg.UserID)
77
+	if err != nil {
78
+		return 0, err
79
+	}
80
+	return result.RowsAffected(), nil
81
+}
82
+
5283
 const getUserEmailByAddress = `-- name: GetUserEmailByAddress :one
5384
 SELECT id, user_id, email, is_primary, verified, verification_token_hash,
5485
        verification_sent_at, verified_at, created_at
@@ -172,6 +203,23 @@ func (q *Queries) MarkUserEmailVerified(ctx context.Context, db DBTX, id int64)
172203
 	return err
173204
 }
174205
 
206
+const setUserEmailPrimary = `-- name: SetUserEmailPrimary :exec
207
+UPDATE user_emails SET is_primary = (id = $2) WHERE user_id = $1
208
+`
209
+
210
+type SetUserEmailPrimaryParams struct {
211
+	UserID int64
212
+	ID     int64
213
+}
214
+
215
+// Atomically unset the existing primary and set the supplied row as
216
+// primary. Caller MUST have already verified the row belongs to the
217
+// user and is verified.
218
+func (q *Queries) SetUserEmailPrimary(ctx context.Context, db DBTX, arg SetUserEmailPrimaryParams) error {
219
+	_, err := db.Exec(ctx, setUserEmailPrimary, arg.UserID, arg.ID)
220
+	return err
221
+}
222
+
175223
 const setVerificationToken = `-- name: SetVerificationToken :exec
176224
 UPDATE user_emails
177225
 SET verification_token_hash = $2,
internal/users/sqlc/user_notification_prefs.sql.goadded
@@ -0,0 +1,76 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: user_notification_prefs.sql
5
+
6
+package usersdb
7
+
8
+import (
9
+	"context"
10
+)
11
+
12
+const deleteUserNotificationPref = `-- name: DeleteUserNotificationPref :exec
13
+DELETE FROM user_notification_prefs WHERE user_id = $1 AND key = $2
14
+`
15
+
16
+type DeleteUserNotificationPrefParams struct {
17
+	UserID int64
18
+	Key    string
19
+}
20
+
21
+func (q *Queries) DeleteUserNotificationPref(ctx context.Context, db DBTX, arg DeleteUserNotificationPrefParams) error {
22
+	_, err := db.Exec(ctx, deleteUserNotificationPref, arg.UserID, arg.Key)
23
+	return err
24
+}
25
+
26
+const listUserNotificationPrefs = `-- name: ListUserNotificationPrefs :many
27
+
28
+SELECT user_id, key, value, updated_at
29
+FROM user_notification_prefs
30
+WHERE user_id = $1
31
+ORDER BY key
32
+`
33
+
34
+// SPDX-License-Identifier: AGPL-3.0-or-later
35
+func (q *Queries) ListUserNotificationPrefs(ctx context.Context, db DBTX, userID int64) ([]UserNotificationPref, error) {
36
+	rows, err := db.Query(ctx, listUserNotificationPrefs, userID)
37
+	if err != nil {
38
+		return nil, err
39
+	}
40
+	defer rows.Close()
41
+	items := []UserNotificationPref{}
42
+	for rows.Next() {
43
+		var i UserNotificationPref
44
+		if err := rows.Scan(
45
+			&i.UserID,
46
+			&i.Key,
47
+			&i.Value,
48
+			&i.UpdatedAt,
49
+		); err != nil {
50
+			return nil, err
51
+		}
52
+		items = append(items, i)
53
+	}
54
+	if err := rows.Err(); err != nil {
55
+		return nil, err
56
+	}
57
+	return items, nil
58
+}
59
+
60
+const upsertUserNotificationPref = `-- name: UpsertUserNotificationPref :exec
61
+INSERT INTO user_notification_prefs (user_id, key, value)
62
+VALUES ($1, $2, $3)
63
+ON CONFLICT (user_id, key) DO UPDATE
64
+SET value = EXCLUDED.value
65
+`
66
+
67
+type UpsertUserNotificationPrefParams struct {
68
+	UserID int64
69
+	Key    string
70
+	Value  []byte
71
+}
72
+
73
+func (q *Queries) UpsertUserNotificationPref(ctx context.Context, db DBTX, arg UpsertUserNotificationPrefParams) error {
74
+	_, err := db.Exec(ctx, upsertUserNotificationPref, arg.UserID, arg.Key, arg.Value)
75
+	return err
76
+}
internal/users/sqlc/users.sql.gomodified
@@ -11,6 +11,33 @@ import (
1111
 	"github.com/jackc/pgx/v5/pgtype"
1212
 )
1313
 
14
+const bumpUserSessionEpoch = `-- name: BumpUserSessionEpoch :exec
15
+UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1
16
+`
17
+
18
+func (q *Queries) BumpUserSessionEpoch(ctx context.Context, db DBTX, id int64) error {
19
+	_, err := db.Exec(ctx, bumpUserSessionEpoch, id)
20
+	return err
21
+}
22
+
23
+const countRecentUsernameChanges = `-- name: CountRecentUsernameChanges :one
24
+SELECT count(*) FROM username_redirects
25
+WHERE user_id = $1 AND changed_at > $2
26
+`
27
+
28
+type CountRecentUsernameChangesParams struct {
29
+	UserID    int64
30
+	ChangedAt pgtype.Timestamptz
31
+}
32
+
33
+// Drives the 3-changes-per-60d cap.
34
+func (q *Queries) CountRecentUsernameChanges(ctx context.Context, db DBTX, arg CountRecentUsernameChangesParams) (int64, error) {
35
+	row := db.QueryRow(ctx, countRecentUsernameChanges, arg.UserID, arg.ChangedAt)
36
+	var count int64
37
+	err := row.Scan(&count)
38
+	return count, err
39
+}
40
+
1441
 const countUsers = `-- name: CountUsers :one
1542
 SELECT count(*) FROM users WHERE deleted_at IS NULL
1643
 `
@@ -26,7 +53,7 @@ const createUser = `-- name: CreateUser :one
2653
 
2754
 INSERT INTO users (username, display_name, password_hash)
2855
 VALUES ($1, $2, $3)
29
-RETURNING id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key
56
+RETURNING id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key, theme, session_epoch
3057
 `
3158
 
3259
 type CreateUserParams struct {
@@ -60,12 +87,14 @@ func (q *Queries) CreateUser(ctx context.Context, db DBTX, arg CreateUserParams)
6087
 		&i.Company,
6188
 		&i.Pronouns,
6289
 		&i.AvatarObjectKey,
90
+		&i.Theme,
91
+		&i.SessionEpoch,
6392
 	)
6493
 	return i, err
6594
 }
6695
 
6796
 const getUserByID = `-- name: GetUserByID :one
68
-SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key
97
+SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key, theme, session_epoch
6998
 FROM users
7099
 WHERE id = $1 AND deleted_at IS NULL
71100
 `
@@ -94,12 +123,14 @@ func (q *Queries) GetUserByID(ctx context.Context, db DBTX, id int64) (User, err
94123
 		&i.Company,
95124
 		&i.Pronouns,
96125
 		&i.AvatarObjectKey,
126
+		&i.Theme,
127
+		&i.SessionEpoch,
97128
 	)
98129
 	return i, err
99130
 }
100131
 
101132
 const getUserByUsername = `-- name: GetUserByUsername :one
102
-SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key
133
+SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key, theme, session_epoch
103134
 FROM users
104135
 WHERE username = $1 AND deleted_at IS NULL
105136
 `
@@ -128,10 +159,92 @@ func (q *Queries) GetUserByUsername(ctx context.Context, db DBTX, username strin
128159
 		&i.Company,
129160
 		&i.Pronouns,
130161
 		&i.AvatarObjectKey,
162
+		&i.Theme,
163
+		&i.SessionEpoch,
164
+	)
165
+	return i, err
166
+}
167
+
168
+const getUserByUsernameIncludingDeleted = `-- name: GetUserByUsernameIncludingDeleted :one
169
+SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key, theme, session_epoch FROM users WHERE username = $1
170
+`
171
+
172
+func (q *Queries) GetUserByUsernameIncludingDeleted(ctx context.Context, db DBTX, username string) (User, error) {
173
+	row := db.QueryRow(ctx, getUserByUsernameIncludingDeleted, username)
174
+	var i User
175
+	err := row.Scan(
176
+		&i.ID,
177
+		&i.Username,
178
+		&i.DisplayName,
179
+		&i.PrimaryEmailID,
180
+		&i.PasswordHash,
181
+		&i.PasswordAlgo,
182
+		&i.PasswordUpdatedAt,
183
+		&i.EmailVerified,
184
+		&i.LastLoginAt,
185
+		&i.SuspendedAt,
186
+		&i.SuspendedReason,
187
+		&i.DeletedAt,
188
+		&i.CreatedAt,
189
+		&i.UpdatedAt,
190
+		&i.Bio,
191
+		&i.Location,
192
+		&i.Website,
193
+		&i.Company,
194
+		&i.Pronouns,
195
+		&i.AvatarObjectKey,
196
+		&i.Theme,
197
+		&i.SessionEpoch,
198
+	)
199
+	return i, err
200
+}
201
+
202
+const getUserIncludingDeleted = `-- name: GetUserIncludingDeleted :one
203
+SELECT id, username, display_name, primary_email_id, password_hash, password_algo, password_updated_at, email_verified, last_login_at, suspended_at, suspended_reason, deleted_at, created_at, updated_at, bio, location, website, company, pronouns, avatar_object_key, theme, session_epoch FROM users WHERE id = $1
204
+`
205
+
206
+// Like GetUserByID but returns the row even when deleted_at IS NOT NULL.
207
+func (q *Queries) GetUserIncludingDeleted(ctx context.Context, db DBTX, id int64) (User, error) {
208
+	row := db.QueryRow(ctx, getUserIncludingDeleted, id)
209
+	var i User
210
+	err := row.Scan(
211
+		&i.ID,
212
+		&i.Username,
213
+		&i.DisplayName,
214
+		&i.PrimaryEmailID,
215
+		&i.PasswordHash,
216
+		&i.PasswordAlgo,
217
+		&i.PasswordUpdatedAt,
218
+		&i.EmailVerified,
219
+		&i.LastLoginAt,
220
+		&i.SuspendedAt,
221
+		&i.SuspendedReason,
222
+		&i.DeletedAt,
223
+		&i.CreatedAt,
224
+		&i.UpdatedAt,
225
+		&i.Bio,
226
+		&i.Location,
227
+		&i.Website,
228
+		&i.Company,
229
+		&i.Pronouns,
230
+		&i.AvatarObjectKey,
231
+		&i.Theme,
232
+		&i.SessionEpoch,
131233
 	)
132234
 	return i, err
133235
 }
134236
 
237
+const getUserSessionEpoch = `-- name: GetUserSessionEpoch :one
238
+SELECT session_epoch FROM users WHERE id = $1
239
+`
240
+
241
+func (q *Queries) GetUserSessionEpoch(ctx context.Context, db DBTX, id int64) (int32, error) {
242
+	row := db.QueryRow(ctx, getUserSessionEpoch, id)
243
+	var session_epoch int32
244
+	err := row.Scan(&session_epoch)
245
+	return session_epoch, err
246
+}
247
+
135248
 const linkUserPrimaryEmail = `-- name: LinkUserPrimaryEmail :exec
136249
 UPDATE users
137250
 SET primary_email_id = $2
@@ -163,6 +276,35 @@ func (q *Queries) MarkUserEmailPrimaryVerified(ctx context.Context, db DBTX, id
163276
 	return err
164277
 }
165278
 
279
+const renameUser = `-- name: RenameUser :exec
280
+UPDATE users
281
+SET username = $2
282
+WHERE id = $1
283
+`
284
+
285
+type RenameUserParams struct {
286
+	ID       int64
287
+	Username string
288
+}
289
+
290
+// Wrapped by the username-change flow inside a tx that also writes
291
+// username_redirects, so the old name becomes a redirect target atomically.
292
+func (q *Queries) RenameUser(ctx context.Context, db DBTX, arg RenameUserParams) error {
293
+	_, err := db.Exec(ctx, renameUser, arg.ID, arg.Username)
294
+	return err
295
+}
296
+
297
+const restoreUserAccount = `-- name: RestoreUserAccount :exec
298
+UPDATE users SET deleted_at = NULL WHERE id = $1
299
+`
300
+
301
+// Clears deleted_at; called when a user logs in within the 14-day grace
302
+// window. The login handler enforces the window check before calling.
303
+func (q *Queries) RestoreUserAccount(ctx context.Context, db DBTX, id int64) error {
304
+	_, err := db.Exec(ctx, restoreUserAccount, id)
305
+	return err
306
+}
307
+
166308
 const softDeleteUser = `-- name: SoftDeleteUser :exec
167309
 UPDATE users
168310
 SET deleted_at = now()
@@ -202,6 +344,22 @@ func (q *Queries) TouchUserLastLogin(ctx context.Context, db DBTX, id int64) err
202344
 	return err
203345
 }
204346
 
347
+const updateUserAvatarKey = `-- name: UpdateUserAvatarKey :exec
348
+UPDATE users
349
+SET avatar_object_key = $2
350
+WHERE id = $1
351
+`
352
+
353
+type UpdateUserAvatarKeyParams struct {
354
+	ID              int64
355
+	AvatarObjectKey pgtype.Text
356
+}
357
+
358
+func (q *Queries) UpdateUserAvatarKey(ctx context.Context, db DBTX, arg UpdateUserAvatarKeyParams) error {
359
+	_, err := db.Exec(ctx, updateUserAvatarKey, arg.ID, arg.AvatarObjectKey)
360
+	return err
361
+}
362
+
205363
 const updateUserPassword = `-- name: UpdateUserPassword :exec
206364
 UPDATE users
207365
 SET password_hash       = $2,
@@ -220,3 +378,51 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, db DBTX, arg UpdateUse
220378
 	_, err := db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash, arg.PasswordAlgo)
221379
 	return err
222380
 }
381
+
382
+const updateUserProfile = `-- name: UpdateUserProfile :exec
383
+UPDATE users
384
+SET display_name = $2,
385
+    bio          = $3,
386
+    location     = $4,
387
+    website      = $5,
388
+    company      = $6,
389
+    pronouns     = $7
390
+WHERE id = $1
391
+`
392
+
393
+type UpdateUserProfileParams struct {
394
+	ID          int64
395
+	DisplayName string
396
+	Bio         string
397
+	Location    string
398
+	Website     string
399
+	Company     string
400
+	Pronouns    string
401
+}
402
+
403
+func (q *Queries) UpdateUserProfile(ctx context.Context, db DBTX, arg UpdateUserProfileParams) error {
404
+	_, err := db.Exec(ctx, updateUserProfile,
405
+		arg.ID,
406
+		arg.DisplayName,
407
+		arg.Bio,
408
+		arg.Location,
409
+		arg.Website,
410
+		arg.Company,
411
+		arg.Pronouns,
412
+	)
413
+	return err
414
+}
415
+
416
+const updateUserTheme = `-- name: UpdateUserTheme :exec
417
+UPDATE users SET theme = $2 WHERE id = $1
418
+`
419
+
420
+type UpdateUserThemeParams struct {
421
+	ID    int64
422
+	Theme string
423
+}
424
+
425
+func (q *Queries) UpdateUserTheme(ctx context.Context, db DBTX, arg UpdateUserThemeParams) error {
426
+	_, err := db.Exec(ctx, updateUserTheme, arg.ID, arg.Theme)
427
+	return err
428
+}