Add polished answer notifications (#1)

Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-27 06:55:24 +00:00
committed by codegirl007
parent 35c8c9f391
commit 418ef93da5
28 changed files with 827 additions and 74 deletions
+52
View File
@@ -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
}
+21
View File
@@ -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)
}
}
}
+18
View File
@@ -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
}
+15
View File
@@ -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] {
+2
View File
@@ -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)
+1
View File
@@ -39,6 +39,7 @@ type User struct {
Name string
PasswordHash string
Role string
Email string
AvatarUrl string
State string
CreatedAt string
+54 -38
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -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