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,
43
        verification_sent_at, verified_at, created_at
43
        verification_sent_at, verified_at, created_at
44
 FROM user_emails
44
 FROM user_emails
45
 WHERE verification_token_hash = $1;
45
 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;
54
 
54
 
55
 -- name: CountUsers :one
55
 -- name: CountUsers :one
56
 SELECT count(*) FROM users WHERE deleted_at IS NULL;
56
 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 {
73
 	Company           string
73
 	Company           string
74
 	Pronouns          string
74
 	Pronouns          string
75
 	AvatarObjectKey   pgtype.Text
75
 	AvatarObjectKey   pgtype.Text
76
+	Theme             string
77
+	SessionEpoch      int32
76
 }
78
 }
77
 
79
 
78
 type UserEmail struct {
80
 type UserEmail struct {
@@ -87,6 +89,13 @@ type UserEmail struct {
87
 	CreatedAt             pgtype.Timestamptz
89
 	CreatedAt             pgtype.Timestamptz
88
 }
90
 }
89
 
91
 
92
+type UserNotificationPref struct {
93
+	UserID    int64
94
+	Key       string
95
+	Value     []byte
96
+	UpdatedAt pgtype.Timestamptz
97
+}
98
+
90
 type UserRecoveryCode struct {
99
 type UserRecoveryCode struct {
91
 	ID          int64
100
 	ID          int64
92
 	UserID      int64
101
 	UserID      int64
internal/users/sqlc/querier.gomodified
@@ -20,6 +20,7 @@ type Querier interface {
20
 	// strictly greater. Returns rows affected — 0 means a replay attempt and
20
 	// strictly greater. Returns rows affected — 0 means a replay attempt and
21
 	// the caller should reject the code.
21
 	// the caller should reject the code.
22
 	BumpTOTPCounter(ctx context.Context, db DBTX, arg BumpTOTPCounterParams) (int64, error)
22
 	BumpTOTPCounter(ctx context.Context, db DBTX, arg BumpTOTPCounterParams) (int64, error)
23
+	BumpUserSessionEpoch(ctx context.Context, db DBTX, id int64) error
23
 	// Sets confirmed_at on a pending row. Returns the number of rows updated;
24
 	// Sets confirmed_at on a pending row. Returns the number of rows updated;
24
 	// callers MUST check this to handle the parallel-enrollment race
25
 	// callers MUST check this to handle the parallel-enrollment race
25
 	// (only one of two concurrent confirms wins).
26
 	// (only one of two concurrent confirms wins).
@@ -31,9 +32,12 @@ type Querier interface {
31
 	// 0 means rejected.
32
 	// 0 means rejected.
32
 	ConsumeRecoveryCode(ctx context.Context, db DBTX, arg ConsumeRecoveryCodeParams) (int64, error)
33
 	ConsumeRecoveryCode(ctx context.Context, db DBTX, arg ConsumeRecoveryCodeParams) (int64, error)
33
 	CountActiveUserTokens(ctx context.Context, db DBTX, userID int64) (int64, error)
34
 	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)
34
 	CountUnusedRecoveryCodes(ctx context.Context, db DBTX, userID int64) (int64, error)
37
 	CountUnusedRecoveryCodes(ctx context.Context, db DBTX, userID int64) (int64, error)
35
 	CountUserSSHKeys(ctx context.Context, db DBTX, userID int64) (int64, error)
38
 	CountUserSSHKeys(ctx context.Context, db DBTX, userID int64) (int64, error)
36
 	CountUsers(ctx context.Context, db DBTX) (int64, error)
39
 	CountUsers(ctx context.Context, db DBTX) (int64, error)
40
+	CountVerifiedUserEmails(ctx context.Context, db DBTX, userID int64) (int64, error)
37
 	// SPDX-License-Identifier: AGPL-3.0-or-later
41
 	// SPDX-License-Identifier: AGPL-3.0-or-later
38
 	CreateEmailVerification(ctx context.Context, db DBTX, arg CreateEmailVerificationParams) (EmailVerification, error)
42
 	CreateEmailVerification(ctx context.Context, db DBTX, arg CreateEmailVerificationParams) (EmailVerification, error)
39
 	// SPDX-License-Identifier: AGPL-3.0-or-later
43
 	// SPDX-License-Identifier: AGPL-3.0-or-later
@@ -44,6 +48,10 @@ type Querier interface {
44
 	CreateUserEmail(ctx context.Context, db DBTX, arg CreateUserEmailParams) (UserEmail, error)
48
 	CreateUserEmail(ctx context.Context, db DBTX, arg CreateUserEmailParams) (UserEmail, error)
45
 	DeleteExpiredEmailVerifications(ctx context.Context, db DBTX) error
49
 	DeleteExpiredEmailVerifications(ctx context.Context, db DBTX) error
46
 	DeleteExpiredPasswordResets(ctx context.Context, db DBTX) error
50
 	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
47
 	DeleteUserRecoveryCodes(ctx context.Context, db DBTX, userID int64) error
55
 	DeleteUserRecoveryCodes(ctx context.Context, db DBTX, userID int64) error
48
 	// Scoped delete: caller must pass the owning user_id so a hijacked
56
 	// Scoped delete: caller must pass the owning user_id so a hijacked
49
 	// handler can never delete keys it doesn't own.
57
 	// handler can never delete keys it doesn't own.
@@ -53,12 +61,16 @@ type Querier interface {
53
 	GetPasswordResetByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (PasswordReset, error)
61
 	GetPasswordResetByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (PasswordReset, error)
54
 	GetUserByID(ctx context.Context, db DBTX, id int64) (User, error)
62
 	GetUserByID(ctx context.Context, db DBTX, id int64) (User, error)
55
 	GetUserByUsername(ctx context.Context, db DBTX, username string) (User, error)
63
 	GetUserByUsername(ctx context.Context, db DBTX, username string) (User, error)
64
+	GetUserByUsernameIncludingDeleted(ctx context.Context, db DBTX, username string) (User, error)
56
 	GetUserEmailByAddress(ctx context.Context, db DBTX, email string) (UserEmail, error)
65
 	GetUserEmailByAddress(ctx context.Context, db DBTX, email string) (UserEmail, error)
57
 	GetUserEmailByID(ctx context.Context, db DBTX, id int64) (UserEmail, error)
66
 	GetUserEmailByID(ctx context.Context, db DBTX, id int64) (UserEmail, error)
58
 	GetUserEmailByVerificationHash(ctx context.Context, db DBTX, verificationTokenHash []byte) (UserEmail, error)
67
 	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)
59
 	// Hot path for sshd's AuthorizedKeysCommand. Index lookup via the UNIQUE
70
 	// Hot path for sshd's AuthorizedKeysCommand. Index lookup via the UNIQUE
60
 	// index on fingerprint_sha256.
71
 	// index on fingerprint_sha256.
61
 	GetUserSSHKeyByFingerprint(ctx context.Context, db DBTX, fingerprintSha256 string) (UserSshKey, error)
72
 	GetUserSSHKeyByFingerprint(ctx context.Context, db DBTX, fingerprintSha256 string) (UserSshKey, error)
73
+	GetUserSessionEpoch(ctx context.Context, db DBTX, id int64) (int32, error)
62
 	GetUserTOTP(ctx context.Context, db DBTX, userID int64) (UserTotp, error)
74
 	GetUserTOTP(ctx context.Context, db DBTX, userID int64) (UserTotp, error)
63
 	// Hot path for the auth middleware. token_hash is UNIQUE; returns at
75
 	// Hot path for the auth middleware. token_hash is UNIQUE; returns at
64
 	// most one row. Caller MUST also check revoked_at IS NULL and
76
 	// most one row. Caller MUST also check revoked_at IS NULL and
@@ -81,6 +93,8 @@ type Querier interface {
81
 	LinkUserPrimaryEmail(ctx context.Context, db DBTX, arg LinkUserPrimaryEmailParams) error
93
 	LinkUserPrimaryEmail(ctx context.Context, db DBTX, arg LinkUserPrimaryEmailParams) error
82
 	ListAuditLogForTarget(ctx context.Context, db DBTX, arg ListAuditLogForTargetParams) ([]AuthAuditLog, error)
94
 	ListAuditLogForTarget(ctx context.Context, db DBTX, arg ListAuditLogForTargetParams) ([]AuthAuditLog, error)
83
 	ListUserEmailsForUser(ctx context.Context, db DBTX, userID int64) ([]UserEmail, error)
95
 	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)
84
 	ListUserSSHKeys(ctx context.Context, db DBTX, userID int64) ([]UserSshKey, error)
98
 	ListUserSSHKeys(ctx context.Context, db DBTX, userID int64) ([]UserSshKey, error)
85
 	ListUserTokens(ctx context.Context, db DBTX, userID int64) ([]UserToken, error)
99
 	ListUserTokens(ctx context.Context, db DBTX, userID int64) ([]UserToken, error)
86
 	// SPDX-License-Identifier: AGPL-3.0-or-later
100
 	// SPDX-License-Identifier: AGPL-3.0-or-later
@@ -92,19 +106,33 @@ type Querier interface {
92
 	MarkUserEmailPrimaryVerified(ctx context.Context, db DBTX, id int64) error
106
 	MarkUserEmailPrimaryVerified(ctx context.Context, db DBTX, id int64) error
93
 	MarkUserEmailVerified(ctx context.Context, db DBTX, id int64) error
107
 	MarkUserEmailVerified(ctx context.Context, db DBTX, id int64) error
94
 	PurgeStaleAuthThrottle(ctx context.Context, db DBTX, windowStartedAt pgtype.Timestamptz) error
108
 	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
95
 	ResetAuthThrottle(ctx context.Context, db DBTX, arg ResetAuthThrottleParams) error
112
 	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
96
 	// Used by user suspension to revoke every active token in one statement.
116
 	// Used by user suspension to revoke every active token in one statement.
97
 	RevokeAllUserTokens(ctx context.Context, db DBTX, userID int64) error
117
 	RevokeAllUserTokens(ctx context.Context, db DBTX, userID int64) error
98
 	// Scoped revoke: caller must pass owning user_id so a hijacked handler
118
 	// Scoped revoke: caller must pass owning user_id so a hijacked handler
99
 	// can never revoke tokens it doesn't own. No-op on already-revoked rows.
119
 	// can never revoke tokens it doesn't own. No-op on already-revoked rows.
100
 	RevokeUserToken(ctx context.Context, db DBTX, arg RevokeUserTokenParams) (int64, error)
120
 	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
101
 	SetVerificationToken(ctx context.Context, db DBTX, arg SetVerificationTokenParams) error
125
 	SetVerificationToken(ctx context.Context, db DBTX, arg SetVerificationTokenParams) error
102
 	SoftDeleteUser(ctx context.Context, db DBTX, id int64) error
126
 	SoftDeleteUser(ctx context.Context, db DBTX, id int64) error
103
 	SuspendUser(ctx context.Context, db DBTX, arg SuspendUserParams) error
127
 	SuspendUser(ctx context.Context, db DBTX, arg SuspendUserParams) error
104
 	TouchSSHKeyLastUsed(ctx context.Context, db DBTX, arg TouchSSHKeyLastUsedParams) error
128
 	TouchSSHKeyLastUsed(ctx context.Context, db DBTX, arg TouchSSHKeyLastUsedParams) error
105
 	TouchUserLastLogin(ctx context.Context, db DBTX, id int64) error
129
 	TouchUserLastLogin(ctx context.Context, db DBTX, id int64) error
106
 	TouchUserTokenLastUsed(ctx context.Context, db DBTX, arg TouchUserTokenLastUsedParams) error
130
 	TouchUserTokenLastUsed(ctx context.Context, db DBTX, arg TouchUserTokenLastUsedParams) error
131
+	UpdateUserAvatarKey(ctx context.Context, db DBTX, arg UpdateUserAvatarKeyParams) error
107
 	UpdateUserPassword(ctx context.Context, db DBTX, arg UpdateUserPasswordParams) error
132
 	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
108
 	// SPDX-License-Identifier: AGPL-3.0-or-later
136
 	// SPDX-License-Identifier: AGPL-3.0-or-later
109
 	// Inserts a new pending TOTP row, or replaces an existing pending row for
137
 	// Inserts a new pending TOTP row, or replaces an existing pending row for
110
 	// the same user. Confirmed rows are NOT replaced — disable+regenerate
138
 	// the same user. Confirmed rows are NOT replaced — disable+regenerate
internal/users/sqlc/user_emails.sql.gomodified
@@ -9,6 +9,17 @@ import (
9
 	"context"
9
 	"context"
10
 )
10
 )
11
 
11
 
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
+
12
 const createUserEmail = `-- name: CreateUserEmail :one
23
 const createUserEmail = `-- name: CreateUserEmail :one
13
 
24
 
14
 INSERT INTO user_emails (user_id, email, is_primary, verified, verification_token_hash, verification_sent_at)
25
 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
49
 	return i, err
60
 	return i, err
50
 }
61
 }
51
 
62
 
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
+
52
 const getUserEmailByAddress = `-- name: GetUserEmailByAddress :one
83
 const getUserEmailByAddress = `-- name: GetUserEmailByAddress :one
53
 SELECT id, user_id, email, is_primary, verified, verification_token_hash,
84
 SELECT id, user_id, email, is_primary, verified, verification_token_hash,
54
        verification_sent_at, verified_at, created_at
85
        verification_sent_at, verified_at, created_at
@@ -172,6 +203,23 @@ func (q *Queries) MarkUserEmailVerified(ctx context.Context, db DBTX, id int64)
172
 	return err
203
 	return err
173
 }
204
 }
174
 
205
 
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
+
175
 const setVerificationToken = `-- name: SetVerificationToken :exec
223
 const setVerificationToken = `-- name: SetVerificationToken :exec
176
 UPDATE user_emails
224
 UPDATE user_emails
177
 SET verification_token_hash = $2,
225
 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 (
11
 	"github.com/jackc/pgx/v5/pgtype"
11
 	"github.com/jackc/pgx/v5/pgtype"
12
 )
12
 )
13
 
13
 
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
+
14
 const countUsers = `-- name: CountUsers :one
41
 const countUsers = `-- name: CountUsers :one
15
 SELECT count(*) FROM users WHERE deleted_at IS NULL
42
 SELECT count(*) FROM users WHERE deleted_at IS NULL
16
 `
43
 `
@@ -26,7 +53,7 @@ const createUser = `-- name: CreateUser :one
26
 
53
 
27
 INSERT INTO users (username, display_name, password_hash)
54
 INSERT INTO users (username, display_name, password_hash)
28
 VALUES ($1, $2, $3)
55
 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
30
 `
57
 `
31
 
58
 
32
 type CreateUserParams struct {
59
 type CreateUserParams struct {
@@ -60,12 +87,14 @@ func (q *Queries) CreateUser(ctx context.Context, db DBTX, arg CreateUserParams)
60
 		&i.Company,
87
 		&i.Company,
61
 		&i.Pronouns,
88
 		&i.Pronouns,
62
 		&i.AvatarObjectKey,
89
 		&i.AvatarObjectKey,
90
+		&i.Theme,
91
+		&i.SessionEpoch,
63
 	)
92
 	)
64
 	return i, err
93
 	return i, err
65
 }
94
 }
66
 
95
 
67
 const getUserByID = `-- name: GetUserByID :one
96
 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
69
 FROM users
98
 FROM users
70
 WHERE id = $1 AND deleted_at IS NULL
99
 WHERE id = $1 AND deleted_at IS NULL
71
 `
100
 `
@@ -94,12 +123,14 @@ func (q *Queries) GetUserByID(ctx context.Context, db DBTX, id int64) (User, err
94
 		&i.Company,
123
 		&i.Company,
95
 		&i.Pronouns,
124
 		&i.Pronouns,
96
 		&i.AvatarObjectKey,
125
 		&i.AvatarObjectKey,
126
+		&i.Theme,
127
+		&i.SessionEpoch,
97
 	)
128
 	)
98
 	return i, err
129
 	return i, err
99
 }
130
 }
100
 
131
 
101
 const getUserByUsername = `-- name: GetUserByUsername :one
132
 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
103
 FROM users
134
 FROM users
104
 WHERE username = $1 AND deleted_at IS NULL
135
 WHERE username = $1 AND deleted_at IS NULL
105
 `
136
 `
@@ -128,10 +159,92 @@ func (q *Queries) GetUserByUsername(ctx context.Context, db DBTX, username strin
128
 		&i.Company,
159
 		&i.Company,
129
 		&i.Pronouns,
160
 		&i.Pronouns,
130
 		&i.AvatarObjectKey,
161
 		&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,
131
 	)
198
 	)
132
 	return i, err
199
 	return i, err
133
 }
200
 }
134
 
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,
233
+	)
234
+	return i, err
235
+}
236
+
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
+
135
 const linkUserPrimaryEmail = `-- name: LinkUserPrimaryEmail :exec
248
 const linkUserPrimaryEmail = `-- name: LinkUserPrimaryEmail :exec
136
 UPDATE users
249
 UPDATE users
137
 SET primary_email_id = $2
250
 SET primary_email_id = $2
@@ -163,6 +276,35 @@ func (q *Queries) MarkUserEmailPrimaryVerified(ctx context.Context, db DBTX, id
163
 	return err
276
 	return err
164
 }
277
 }
165
 
278
 
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
+
166
 const softDeleteUser = `-- name: SoftDeleteUser :exec
308
 const softDeleteUser = `-- name: SoftDeleteUser :exec
167
 UPDATE users
309
 UPDATE users
168
 SET deleted_at = now()
310
 SET deleted_at = now()
@@ -202,6 +344,22 @@ func (q *Queries) TouchUserLastLogin(ctx context.Context, db DBTX, id int64) err
202
 	return err
344
 	return err
203
 }
345
 }
204
 
346
 
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
+
205
 const updateUserPassword = `-- name: UpdateUserPassword :exec
363
 const updateUserPassword = `-- name: UpdateUserPassword :exec
206
 UPDATE users
364
 UPDATE users
207
 SET password_hash       = $2,
365
 SET password_hash       = $2,
@@ -220,3 +378,51 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, db DBTX, arg UpdateUse
220
 	_, err := db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash, arg.PasswordAlgo)
378
 	_, err := db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash, arg.PasswordAlgo)
221
 	return err
379
 	return err
222
 }
380
 }
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
+}