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
+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
}