Notify question authors via Resend when an admin posts the first answer.
Require email at registration (editable on profile), add a Resend mailer with idempotent first-answer sends, and keep answer saves independent of delivery.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
minEmailLen = 3
|
||||
maxEmailLen = 254
|
||||
)
|
||||
|
||||
// NormalizeEmail trims and lowercases an address for storage/comparison.
|
||||
func NormalizeEmail(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// ValidateEmail returns a normalized address or an error message suitable for UI.
|
||||
func ValidateEmail(raw string) (normalized string, errMsg string) {
|
||||
normalized = NormalizeEmail(raw)
|
||||
if normalized == "" {
|
||||
return "", "Email is required."
|
||||
}
|
||||
n := utf8.RuneCountInString(normalized)
|
||||
if n < minEmailLen || len(normalized) > maxEmailLen {
|
||||
return "", "Enter a valid email address."
|
||||
}
|
||||
addr, err := mail.ParseAddress(normalized)
|
||||
if err != nil || addr.Address != normalized {
|
||||
return "", "Enter a valid email address."
|
||||
}
|
||||
at := strings.LastIndex(normalized, "@")
|
||||
if at < 1 || at == len(normalized)-1 {
|
||||
return "", "Enter a valid email address."
|
||||
}
|
||||
domain := normalized[at+1:]
|
||||
if !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||
return "", "Enter a valid email address."
|
||||
}
|
||||
return normalized, ""
|
||||
}
|
||||
|
||||
// MustValidateEmail is like ValidateEmail but returns a Go error.
|
||||
func MustValidateEmail(raw string) (string, error) {
|
||||
n, msg := ValidateEmail(raw)
|
||||
if msg != "" {
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want, err string
|
||||
}{
|
||||
{"", "", "Email is required."},
|
||||
{" Alice@Example.COM ", "alice@example.com", ""},
|
||||
{"not-an-email", "", "Enter a valid email address."},
|
||||
{"a@b", "", "Enter a valid email address."},
|
||||
{"ok@example.com", "ok@example.com", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, msg := ValidateEmail(tc.in)
|
||||
if got != tc.want || msg != tc.err {
|
||||
t.Fatalf("%q: got (%q, %q) want (%q, %q)", tc.in, got, msg, tc.want, tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if _, ok := m.byName[u.Username]; ok {
|
||||
return ErrDuplicateUsername
|
||||
}
|
||||
if u.Email != "" {
|
||||
for _, existing := range m.users {
|
||||
if existing.Email == u.Email {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -179,11 +187,21 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
email := NormalizeEmail(u.Email)
|
||||
if email != "" {
|
||||
for id, existing := range m.users {
|
||||
if id != u.ID && existing.Email == email {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
cur.State = strings.TrimSpace(u.State)
|
||||
cur.Email = email
|
||||
if u.AvatarURL != "" {
|
||||
cur.AvatarURL = u.AvatarURL
|
||||
}
|
||||
u.State = cur.State
|
||||
u.Email = cur.Email
|
||||
u.AvatarURL = cur.AvatarURL
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,20 @@ func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateUserEmail adds email and a partial unique index on lower(email).
|
||||
func migrateUserEmail(ctx context.Context, exec execContext) error {
|
||||
if _, err := exec.ExecContext(ctx, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("add column email: %w", err)
|
||||
}
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
|
||||
ON users (lower(email))
|
||||
WHERE email <> ''`); err != nil {
|
||||
return fmt.Errorf("users_email_lower_uidx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type execContext interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
@@ -66,6 +80,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
return applySchema(ctx, exec, schemaSQL)
|
||||
}},
|
||||
{"002_user_profile_columns", migrateUserProfileColumns},
|
||||
{"003_user_email", migrateUserEmail},
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
|
||||
@@ -29,6 +29,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -69,6 +70,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(role),
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}); err != nil {
|
||||
return mapUniqueViolation(err)
|
||||
|
||||
@@ -39,6 +39,7 @@ type User struct {
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
|
||||
@@ -24,8 +24,8 @@ func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :exec
|
||||
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, '', '', $6)
|
||||
INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', '', $7)
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -34,6 +34,7 @@ type CreateUserParams struct {
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Email string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
@@ -44,13 +45,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
|
||||
arg.Name,
|
||||
arg.PasswordHash,
|
||||
arg.Role,
|
||||
arg.Email,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
SELECT id, username, name, role, email, avatar_url, state, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -60,6 +62,7 @@ type GetUserByIDRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -73,6 +76,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -81,7 +85,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
|
||||
SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
@@ -91,6 +95,7 @@ type GetUserByUsernameRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -105,6 +110,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -127,12 +133,13 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
SELECT id, username, name, role, email, avatar_url, state, created_at
|
||||
FROM users
|
||||
WHERE (
|
||||
$1 = ''
|
||||
OR username ILIKE '%' || $1 || '%'
|
||||
OR name ILIKE '%' || $1 || '%'
|
||||
OR email ILIKE '%' || $1 || '%'
|
||||
)
|
||||
AND (
|
||||
$2 = ''
|
||||
@@ -155,6 +162,7 @@ type ListUsersRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -179,6 +187,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -196,6 +205,46 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserProfile = `-- name: UpdateUserProfile :exec
|
||||
UPDATE users
|
||||
SET state = $1, email = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
State string
|
||||
Email string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserProfile, arg.State, arg.Email, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserProfileAndAvatar = `-- name: UpdateUserProfileAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, email = $2, avatar_url = $3
|
||||
WHERE id = $4
|
||||
`
|
||||
|
||||
type UpdateUserProfileAndAvatarParams struct {
|
||||
State string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserProfileAndAvatar(ctx context.Context, arg UpdateUserProfileAndAvatarParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserProfileAndAvatar,
|
||||
arg.State,
|
||||
arg.Email,
|
||||
arg.AvatarUrl,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserRole = `-- name: UpdateUserRole :execresult
|
||||
UPDATE users
|
||||
SET role = $1
|
||||
@@ -210,36 +259,3 @@ type UpdateUserRoleParams struct {
|
||||
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
|
||||
}
|
||||
|
||||
const updateUserState = `-- name: UpdateUserState :exec
|
||||
UPDATE users
|
||||
SET state = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateUserStateParams struct {
|
||||
State string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, avatar_url = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
type UpdateUserStateAndAvatarParams struct {
|
||||
State string
|
||||
AvatarUrl string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
+18
-8
@@ -30,6 +30,7 @@ type User struct {
|
||||
Username string
|
||||
Name string
|
||||
Role Role
|
||||
Email string
|
||||
AvatarURL string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -50,12 +51,13 @@ func NormalizeUsername(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
|
||||
func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
|
||||
return &User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Role: Role(role),
|
||||
Email: email,
|
||||
AvatarURL: avatarURL,
|
||||
State: state,
|
||||
CreatedAt: createdAt,
|
||||
@@ -73,6 +75,7 @@ func (u *User) Create(ctx context.Context) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func (u *User) Create(ctx context.Context) error {
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(u.Role),
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}))
|
||||
}
|
||||
@@ -149,21 +153,27 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveProfile writes State and optionally AvatarURL.
|
||||
// SaveProfile writes Email, State, and optionally AvatarURL.
|
||||
func (u *User) SaveProfile(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
u.State = strings.TrimSpace(u.State)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
q := sqlc.New(u.db)
|
||||
if u.AvatarURL == "" {
|
||||
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
|
||||
return mapUniqueViolation(q.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{
|
||||
State: u.State,
|
||||
Email: u.Email,
|
||||
ID: u.ID,
|
||||
}))
|
||||
}
|
||||
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
|
||||
return mapUniqueViolation(q.UpdateUserProfileAndAvatar(ctx, sqlc.UpdateUserProfileAndAvatarParams{
|
||||
State: u.State,
|
||||
Email: u.Email,
|
||||
AvatarUrl: u.AvatarURL,
|
||||
ID: u.ID,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
||||
@@ -187,7 +197,7 @@ func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, strin
|
||||
}
|
||||
out := make([]User, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
|
||||
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, "")
|
||||
out = append(out, *u)
|
||||
}
|
||||
var nextCreated, nextID string
|
||||
@@ -204,7 +214,7 @@ func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
|
||||
}
|
||||
|
||||
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
|
||||
@@ -212,5 +222,5 @@ func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
@@ -14,6 +15,9 @@ import (
|
||||
// ErrDuplicateUsername is returned when inserting a username that already exists.
|
||||
var ErrDuplicateUsername = errors.New("username taken")
|
||||
|
||||
// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
|
||||
var ErrDuplicateEmail = errors.New("email taken")
|
||||
|
||||
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
|
||||
var ErrHiddenOrMissing = errors.New("question not votable")
|
||||
|
||||
@@ -58,6 +62,9 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int)
|
||||
func mapUniqueViolation(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
return ErrDuplicateUsername
|
||||
}
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user