From 418ef93da5d24bf5c2e3eada5254321b5917f392 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 06:55:24 +0000 Subject: [PATCH] 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 --- .env.example | 5 + cmd/server/main.go | 10 +- db/queries/users.sql | 25 ++-- go.mod | 1 + go.sum | 2 + internal/mail/mail.go | 189 +++++++++++++++++++++++++++++++ internal/mail/mail_test.go | 76 +++++++++++++ internal/mail/mark.png | Bin 0 -> 16541 bytes internal/mail/recording.go | 34 ++++++ internal/store/email.go | 52 +++++++++ internal/store/email_test.go | 21 ++++ internal/store/memory.go | 18 +++ internal/store/migrate.go | 15 +++ internal/store/postgres_store.go | 2 + internal/store/sqlc/models.go | 1 + internal/store/sqlc/users.sql.go | 92 ++++++++------- internal/store/user.go | 26 +++-- internal/store/vote.go | 7 ++ internal/web/auth.go | 15 ++- internal/web/profile.go | 36 ++++-- internal/web/server.go | 46 ++++++++ internal/web/server_test.go | 156 ++++++++++++++++++++++++- schema.sql | 5 + static/app.css | 38 +++++++ templates/partials/_answer.html | 16 +++ templates/profile.html | 4 + templates/question.html | 6 +- templates/register.html | 3 + 28 files changed, 827 insertions(+), 74 deletions(-) create mode 100644 internal/mail/mail.go create mode 100644 internal/mail/mail_test.go create mode 100644 internal/mail/mark.png create mode 100644 internal/mail/recording.go create mode 100644 internal/store/email.go create mode 100644 internal/store/email_test.go diff --git a/.env.example b/.env.example index 71f6ffc..61c32b2 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ SECURE_COOKIE=0 # Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For # (direct peer must match). Leave unset to ignore XFF and use RemoteAddr. # TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16 +# Resend (answer notifications). Leave RESEND_API_KEY unset to disable. +# RESEND_API_KEY=re_xxxxxxxxx +# RESEND_FROM=Ask a Plumber +# Public site origin used in email links (required when Resend is enabled): +# APP_BASE_URL=https://askaplumber.example # DigitalOcean Spaces (profile avatars). Leave unset to disable uploads. # SPACES_KEY= # SPACES_SECRET= diff --git a/cmd/server/main.go b/cmd/server/main.go index ec20f60..28e6e17 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "plumber" "plumber/internal/blob" + "plumber/internal/mail" "plumber/internal/store" "plumber/internal/web" ) @@ -29,7 +30,11 @@ func main() { defer sessions.Close() uploader := blob.FromEnv() - handler := newHandler(db, sessions, uploader) + notifier, err := mail.FromEnv() + if err != nil { + log.Fatalf("mail: %v", err) + } + handler := newHandler(db, sessions, uploader, notifier) run(&http.Server{ Addr: listenAddr(), Handler: handler, @@ -53,12 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) { return db, sessions } -func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler { +func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler { srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), SecureCookie: secureCookieFromEnv(), TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")), Blob: uploader, + Mail: notifier, }) if err != nil { log.Fatalf("server: %v", err) diff --git a/db/queries/users.sql b/db/queries/users.sql index 02e4271..74edc5f 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -1,24 +1,25 @@ -- 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); -- 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; -- 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; -- 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 ( sqlc.arg(search) = '' OR username ILIKE '%' || sqlc.arg(search) || '%' OR name ILIKE '%' || sqlc.arg(search) || '%' + OR email ILIKE '%' || sqlc.arg(search) || '%' ) AND ( sqlc.arg(cursor_created) = '' @@ -43,12 +44,12 @@ UPDATE users SET role = $1 WHERE id = $2; --- name: UpdateUserState :exec +-- name: UpdateUserProfile :exec UPDATE users -SET state = $1 -WHERE id = $2; - --- name: UpdateUserStateAndAvatar :exec -UPDATE users -SET state = $1, avatar_url = $2 +SET state = $1, email = $2 WHERE id = $3; + +-- name: UpdateUserProfileAndAvatar :exec +UPDATE users +SET state = $1, email = $2, avatar_url = $3 +WHERE id = $4; diff --git a/go.mod b/go.mod index ccba0df..2ff2b4b 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/resend/resend-go/v3 v3.16.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 0a8c3d7..479e538 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE= +github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..86084d6 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,189 @@ +package mail + +import ( + "context" + _ "embed" + "fmt" + "html" + "os" + "strings" + + "github.com/resend/resend-go/v3" +) + +//go:embed mark.png +var markPNG []byte + +// QuestionAnswered is the payload for notifying a question author of a reply. +type QuestionAnswered struct { + ToEmail string + ToName string + QuestionID string + QuestionTitle string + AnswerBody string +} + +// Notifier sends transactional email about answered questions. +type Notifier interface { + NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error +} + +// Nop is a no-op Notifier used when Resend is not configured. +type Nop struct{} + +func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil } + +// Resend sends via the Resend HTTP API. +type Resend struct { + client *resend.Client + from string + baseURL string +} + +// FromEnv builds a Notifier from RESEND_* and APP_BASE_URL. +// Returns Nop when RESEND_API_KEY is unset. +func FromEnv() (Notifier, error) { + key := strings.TrimSpace(os.Getenv("RESEND_API_KEY")) + if key == "" { + return Nop{}, nil + } + from := strings.TrimSpace(os.Getenv("RESEND_FROM")) + base := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/") + if from == "" { + return nil, fmt.Errorf("RESEND_FROM is required when RESEND_API_KEY is set") + } + if base == "" { + return nil, fmt.Errorf("APP_BASE_URL is required when RESEND_API_KEY is set") + } + return &Resend{ + client: resend.NewClient(key), + from: from, + baseURL: base, + }, nil +} + +func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error { + if r == nil || r.client == nil { + return nil + } + to := strings.TrimSpace(msg.ToEmail) + if to == "" { + return nil + } + text, htmlBody := questionAnsweredContent(r.baseURL, msg) + params := &resend.SendEmailRequest{ + From: r.from, + To: []string{to}, + Subject: "Your question was answered", + Text: text, + Html: htmlBody, + Attachments: []*resend.Attachment{{ + Content: markPNG, + Filename: "ask-a-plumber-first.png", + ContentType: "image/png", + ContentId: "answer-notification-mark", + }}, + } + opts := &resend.SendEmailOptions{ + IdempotencyKey: "answer-notify:" + msg.QuestionID, + } + _, err := r.client.Emails.SendWithOptions(ctx, params, opts) + return err +} + +func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) { + link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID + title := strings.TrimSpace(msg.QuestionTitle) + if title == "" { + title = "your question" + } + text := fmt.Sprintf( + "Hi%s,\n\nYour question %q has an answer from a plumber:\n\n%s\n\nView it here:\n%s\n", + greetingName(msg.ToName), + title, + msg.AnswerBody, + link, + ) + htmlBody := strings.NewReplacer( + "{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."), + "{{GREETING}}", html.EscapeString(greetingName(msg.ToName)), + "{{TITLE}}", html.EscapeString(title), + "{{ANSWER}}", html.EscapeString(msg.AnswerBody), + "{{LINK}}", html.EscapeString(link), + "{{MARK}}", "cid:answer-notification-mark", + ).Replace(questionAnsweredHTML) + return text, htmlBody +} + +const questionAnsweredHTML = ` + + + + + + + Your question was answered + + +
{{PREHEADER}}
+ + + + +
+ + + + + + + + + + +
+ + + + + +
+ + +
Ask a Plumber First
+
Bay Area · Shop Dispatch
+
+
+
Shop response
+

Your question has an answer.

+

Hi{{GREETING}}, a plumber replied to:

+

“{{TITLE}}”

+ + + + +
+
The answer
+
{{ANSWER}}
+
+ + + + +
+ View the answer → +
+
+ You received this because you asked a question on Ask a Plumber First. +
+
+ +` + +func greetingName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return " " + name +} diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 0000000..624505c --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,76 @@ +package mail + +import ( + "bytes" + "strings" + "testing" +) + +func TestEmbeddedMarkIsPNG(t *testing.T) { + t.Parallel() + + if !bytes.HasPrefix(markPNG, []byte("\x89PNG\r\n\x1a\n")) { + t.Fatal("embedded mark is not PNG data") + } +} + +func TestQuestionAnsweredContent(t *testing.T) { + t.Parallel() + + text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{ + ToName: ``, + QuestionID: "question-123", + QuestionTitle: `Leaky sink`, + AnswerBody: "Replace the cartridge.\nThen test the handle. ", + }) + + for _, want := range []string{ + "Ask a Plumber First", + "Shop response", + "cid:answer-notification-mark", + "https://plumber.example/questions/question-123", + "white-space:pre-wrap", + "<Sam & Pat>", + "<b>Leaky sink</b>", + "<script>alert('x')</script>", + } { + if !strings.Contains(htmlBody, want) { + t.Errorf("HTML missing %q", want) + } + } + for _, unsafe := range []string{ + "", + "Leaky sink", + "", + } { + if strings.Contains(htmlBody, unsafe) { + t.Errorf("HTML contains unescaped content %q", unsafe) + } + } + if strings.Contains(htmlBody, "{{") { + t.Error("HTML contains an unresolved template token") + } + for _, want := range []string{ + `Hi ,`, + `Your question "Leaky sink"`, + "Replace the cartridge.\nThen test the handle.", + "https://plumber.example/questions/question-123", + } { + if !strings.Contains(text, want) { + t.Errorf("text missing %q", want) + } + } +} + +func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) { + t.Parallel() + + text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{}) + if !strings.Contains(text, `"your question"`) { + t.Errorf("text missing fallback title") + } + if !strings.Contains(htmlBody, "a plumber replied to:

") || + !strings.Contains(htmlBody, "“your question”") { + t.Errorf("HTML missing fallback title") + } +} diff --git a/internal/mail/mark.png b/internal/mail/mark.png new file mode 100644 index 0000000000000000000000000000000000000000..59feb94133736ea448d3c004990d1fffc71c6fd4 GIT binary patch literal 16541 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 by#;7s#Jg%p=&JGO^a0Nm)fI}LT8950257hp literal 0 HcmV?d00001 diff --git a/internal/mail/recording.go b/internal/mail/recording.go new file mode 100644 index 0000000..188eac8 --- /dev/null +++ b/internal/mail/recording.go @@ -0,0 +1,34 @@ +package mail + +import ( + "context" + "sync" +) + +// Recording is a test Notifier that records calls. +type Recording struct { + mu sync.Mutex + Msgs []QuestionAnswered +} + +func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Msgs = append(r.Msgs, msg) + return nil +} + +func (r *Recording) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.Msgs) +} + +// Snapshot returns a copy of recorded messages. +func (r *Recording) Snapshot() []QuestionAnswered { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]QuestionAnswered, len(r.Msgs)) + copy(out, r.Msgs) + return out +} diff --git a/internal/store/email.go b/internal/store/email.go new file mode 100644 index 0000000..cf95199 --- /dev/null +++ b/internal/store/email.go @@ -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 +} diff --git a/internal/store/email_test.go b/internal/store/email_test.go new file mode 100644 index 0000000..83f7505 --- /dev/null +++ b/internal/store/email_test.go @@ -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) + } + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go index 93e6280..60e3170 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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 } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index abbe1b4..ce91c2f 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -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] { diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go index f0f92a4..a67d906 100644 --- a/internal/store/postgres_store.go +++ b/internal/store/postgres_store.go @@ -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) diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 7de647f..5fdb986 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -39,6 +39,7 @@ type User struct { Name string PasswordHash string Role string + Email string AvatarUrl string State string CreatedAt string diff --git a/internal/store/sqlc/users.sql.go b/internal/store/sqlc/users.sql.go index ffa9085..f7dae6f 100644 --- a/internal/store/sqlc/users.sql.go +++ b/internal/store/sqlc/users.sql.go @@ -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 -} diff --git a/internal/store/user.go b/internal/store/user.go index 299c616..02efe70 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -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 } diff --git a/internal/store/vote.go b/internal/store/vote.go index 2d24a3c..5a3f831 100644 --- a/internal/store/vote.go +++ b/internal/store/vote.go @@ -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 diff --git a/internal/web/auth.go b/internal/web/auth.go index ed1d2c1..d6df74b 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -129,14 +129,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } username := strings.TrimSpace(r.PostFormValue("username")) + emailRaw := r.PostFormValue("email") password := r.PostFormValue("password") setupSecret := r.PostFormValue("setup_secret") - p := authPage{page: s.basePage(r, "Create account"), Username: username} + p := authPage{page: s.basePage(r, "Create account"), Username: username, Email: strings.TrimSpace(emailRaw)} if !usernameRe.MatchString(username) { p.Error = "Username must be 3–20 letters, numbers, or underscores." s.exec(w, "register", p) return } + email, emailErr := store.ValidateEmail(emailRaw) + if emailErr != "" { + p.Error = emailErr + s.exec(w, "register", p) + return + } if ok, msg := passwordValid(password); !ok { p.Error = msg s.exec(w, "register", p) @@ -153,6 +160,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } u := &store.User{ Username: username, + Email: email, PasswordHash: string(hash), Role: role, } @@ -162,6 +170,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { s.exec(w, "register", p) return } + if errors.Is(err, store.ErrDuplicateEmail) { + p.Error = "That email is already registered." + s.exec(w, "register", p) + return + } log.Printf("register create: %v", err) http.Error(w, "could not create account", http.StatusInternalServerError) return diff --git a/internal/web/profile.go b/internal/web/profile.go index d76a2db..c2e2566 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -2,6 +2,7 @@ package web import ( "bytes" + "errors" "fmt" "image" "image/jpeg" @@ -27,6 +28,7 @@ type profilePage struct { UploadsEnabled bool Error string StateVal string + EmailVal string } func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) { @@ -35,7 +37,7 @@ func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther) return } - s.renderProfile(w, r, u, "", u.State) + s.renderProfile(w, r, u, "", u.State, u.Email) } func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { @@ -45,7 +47,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseMultipartForm(3 << 20); err != nil { - s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State) + s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State, u.Email) return } want := s.sessions.GetString(r.Context(), "csrf") @@ -57,7 +59,12 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { state := geo.NormalizeState(r.FormValue("state")) if !geo.ValidState(state) { - s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state) + s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state, r.FormValue("email")) + return + } + email, emailErr := store.ValidateEmail(r.FormValue("email")) + if emailErr != "" { + s.renderProfile(w, r, u, emailErr, state, r.FormValue("email")) return } @@ -67,16 +74,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { if err == nil { defer file.Close() if !s.cfg.Blob.Enabled() { - s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state) + s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state, email) return } if hdr.Size > 2<<20 { - s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state) + s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state, email) return } body, ext, contentType, prepErr := prepareAvatar(file, 2<<20) if prepErr != nil { - s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state) + s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state, email) return } prevURL := u.AvatarURL @@ -88,14 +95,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { Size: int64(len(body)), }) if upErr != nil { - s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state) + s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state, email) return } avatarURL = url u.State = state + u.Email = email u.AvatarURL = avatarURL if err := s.store.SaveUserProfile(r.Context(), u); err != nil { _ = s.cfg.Blob.Delete(r.Context(), avatarKey) + if errors.Is(err, store.ErrDuplicateEmail) { + s.renderProfile(w, r, u, "That email is already registered.", state, email) + return + } http.Error(w, "could not save profile", http.StatusInternalServerError) return } @@ -106,12 +118,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/profile", http.StatusSeeOther) return } else if err != http.ErrMissingFile { - s.renderProfile(w, r, u, "Could not read avatar file.", state) + s.renderProfile(w, r, u, "Could not read avatar file.", state, email) return } u.State = state + u.Email = email if err := s.store.SaveUserProfile(r.Context(), u); err != nil { + if errors.Is(err, store.ErrDuplicateEmail) { + s.renderProfile(w, r, u, "That email is already registered.", state, email) + return + } http.Error(w, "could not save profile", http.StatusInternalServerError) return } @@ -236,7 +253,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image { return dst } -func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) { +func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) { var ( questions []store.RankedQuestion label string @@ -263,5 +280,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store. UploadsEnabled: s.cfg.Blob.Enabled(), Error: errMsg, StateVal: stateVal, + EmailVal: emailVal, }) } diff --git a/internal/web/server.go b/internal/web/server.go index 7950bc6..bc88f5b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -22,6 +22,7 @@ import ( "plumber/internal/blob" "plumber/internal/geo" + "plumber/internal/mail" "plumber/internal/pacific" "plumber/internal/store" ) @@ -34,6 +35,7 @@ type Config struct { // TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer). TrustedProxies []*net.IPNet Blob blob.Uploader + Mail mail.Notifier } type Server struct { @@ -82,6 +84,7 @@ type submitPage struct { type authPage struct { page Username string + Email string Error string Next string } @@ -98,6 +101,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F if cfg.Blob == nil { cfg.Blob = blob.Disabled{} } + if cfg.Mail == nil { + cfg.Mail = mail.Nop{} + } funcMap := template.FuncMap{ "voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx { return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q} @@ -482,6 +488,17 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { if len(body) > 12000 { body = truncateRunes(body, 12000) } + q, err := s.store.GetQuestion(r.Context(), id, u.ID) + if err != nil { + http.NotFound(w, r) + return + } + _, priorErr := s.store.GetAnswer(r.Context(), id) + wasNew := errors.Is(priorErr, sql.ErrNoRows) + if priorErr != nil && !wasNew { + http.Error(w, "could not load answer", http.StatusInternalServerError) + return + } ans := &store.Answer{ QuestionID: id, AuthorID: u.ID, @@ -491,6 +508,9 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not save answer", http.StatusInternalServerError) return } + if wasNew { + s.notifyQuestionAnswered(q, body, u.ID) + } saved, err := s.store.GetAnswer(r.Context(), id) if err != nil { http.Error(w, "could not load answer", http.StatusInternalServerError) @@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther) } +func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) { + if q == nil || s.cfg.Mail == nil { + return + } + author, err := s.store.UserByID(context.Background(), q.AuthorID) + if err != nil || author == nil || author.Email == "" || author.ID == adminID { + return + } + msg := mail.QuestionAnswered{ + ToEmail: author.Email, + ToName: author.Name, + QuestionID: q.ID, + QuestionTitle: q.Title, + AnswerBody: answerBody, + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil { + log.Printf("notify answer %s: %v", q.ID, err) + return + } + log.Printf("notify answer %s: accepted", q.ID) + }() +} + func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) { if !s.requireCSRF(w, r) { return diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 7419318..b86ffa1 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -19,6 +19,7 @@ import ( "plumber" "plumber/internal/blob" + "plumber/internal/mail" "plumber/internal/pacific" "plumber/internal/store" ) @@ -53,6 +54,7 @@ func seedUser(t *testing.T, st store.Store, username, password string, role stor } u := &store.User{ Username: username, + Email: username + "@example.com", PasswordHash: string(hash), Role: role, } @@ -114,7 +116,7 @@ func registerUser(t *testing.T, h http.Handler, username, password string, setup pre := rec.Result().Cookies() preToken := sessionValue(pre) csrf := csrfFrom(rec.Body.String()) - form := "_csrf=" + csrf + "&username=" + username + "&password=" + password + form := "_csrf=" + csrf + "&username=" + username + "&email=" + username + "%40example.com&password=" + password if len(setupSecret) > 0 && setupSecret[0] != "" { form += "&setup_secret=" + setupSecret[0] } @@ -362,6 +364,7 @@ func TestProfilePageAndState(t *testing.T) { var buf bytes.Buffer w := multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("email", name+"@example.com") _ = w.WriteField("state", "CA") _ = w.Close() req = httptest.NewRequest(http.MethodPost, "/profile", &buf) @@ -389,6 +392,7 @@ func TestProfilePageAndState(t *testing.T) { buf.Reset() w = multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("email", name+"@example.com") _ = w.WriteField("state", "ZZ") _ = w.Close() req = httptest.NewRequest(http.MethodPost, "/profile", &buf) @@ -449,6 +453,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { var buf bytes.Buffer w := multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) + _ = w.WriteField("email", hubName+"@example.com") _ = w.WriteField("state", "OR") part, err := w.CreateFormFile("avatar", "pic.png") if err != nil { @@ -596,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") { t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String()) } + if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) || + !strings.Contains(body, "Edit answer") || + !strings.Contains(body, ">Tighten the nuts.") || + !strings.Contains(body, `type="reset" class="btn btn-ghost"`) || + !strings.Contains(body, `removeAttribute('open')`) || + strings.Contains(body, `
0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(msgs) != 1 { + t.Fatalf("first answer notifies once, got %d", len(msgs)) + } + if msgs[0].ToEmail != asker.Email || msgs[0].QuestionID != q.ID { + t.Fatalf("unexpected notify: %+v", msgs[0]) + } + if msgs[0].AnswerBody != "First reply" { + t.Fatalf("answer body %q", msgs[0].AnswerBody) + } + + postAnswer("Edited+reply") + time.Sleep(50 * time.Millisecond) + if recMail.Len() != 1 { + t.Fatalf("edit must not notify again, got %d", recMail.Len()) + } + + // Author without email is skipped + recMail2 := &mail.Recording{} + srv2, mem2 := newTestServer(t, Config{Mail: recMail2}) + h2 := srv2.Handler() + admin2 := seedUser(t, mem2, uniq("adm2"), "hunter22", store.RoleAdmin) + noMail := &store.User{Username: uniq("silent"), PasswordHash: admin.PasswordHash, Role: store.RoleUser, Email: ""} + hash, _ := bcrypt.GenerateFromPassword([]byte("hunter22"), bcrypt.MinCost) + noMail.PasswordHash = string(hash) + if err := mem2.CreateUser(context.Background(), noMail); err != nil { + t.Fatal(err) + } + q2 := &store.RankedQuestion{AuthorID: noMail.ID, Title: "Quiet", Body: "x", HuntDate: pacific.Today()} + if err := mem2.CreateQuestion(context.Background(), q2); err != nil { + t.Fatal(err) + } + cookies := loginUser(t, h2, admin2.Username, "hunter22") + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/questions/"+q2.ID, nil) + for _, c := range cookies { + req.AddCookie(c) + } + h2.ServeHTTP(w, req) + csrf := csrfFrom(w.Body.String()) + form := strings.NewReader("_csrf=" + csrf + "&body=Hello") + req = httptest.NewRequest(http.MethodPost, "/questions/"+q2.ID+"/answer", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + req.AddCookie(c) + } + w = httptest.NewRecorder() + h2.ServeHTTP(w, req) + time.Sleep(50 * time.Millisecond) + if recMail2.Len() != 0 { + t.Fatalf("empty email must skip notify, got %d", recMail2.Len()) + } +} + // TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For // cannot bypass rate limits when the direct peer is outside TrustedProxies. // This must go through Handler() so middleware ordering bugs are caught. diff --git a/schema.sql b/schema.sql index 67136d6..d075baa 100644 --- a/schema.sql +++ b/schema.sql @@ -4,11 +4,16 @@ CREATE TABLE IF NOT EXISTS users ( name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), + email TEXT NOT NULL DEFAULT '', avatar_url TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx + ON users (lower(email)) + WHERE email <> ''; + CREATE TABLE IF NOT EXISTS questions ( id TEXT PRIMARY KEY, author_id TEXT NOT NULL REFERENCES users(id), diff --git a/static/app.css b/static/app.css index 7fec899..de617bc 100644 --- a/static/app.css +++ b/static/app.css @@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: font-size: 0.72rem; } +.answer-editor { + margin-top: 16px; + border-top: 1px solid var(--line); +} + +.answer-editor summary { + display: flex; + width: fit-content; + min-height: 44px; + align-items: center; + color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: underline; + cursor: pointer; + list-style: none; +} + +.answer-editor summary::-webkit-details-marker { display: none; } +.answer-editor summary:hover, +.answer-editor[open] summary { color: var(--signal); } +.answer-editor summary:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; +} + +.answer-editor .answer-form { margin-top: 4px; } + +.answer-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.answer-form-actions .btn { flex: 1 1 10rem; } + .waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; } .auth-wrap { diff --git a/templates/partials/_answer.html b/templates/partials/_answer.html index f50904a..ceca9fd 100644 --- a/templates/partials/_answer.html +++ b/templates/partials/_answer.html @@ -5,6 +5,22 @@

Answer

{{.Answer.Body}}

+ {{if isAdmin .User}} +
+ Edit answer +
+ + + +
+ + +
+
+
+ {{end}} {{else}}

No answer yet. Check back after the hunt.

{{end}} diff --git a/templates/profile.html b/templates/profile.html index 2bd4176..3878541 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -25,6 +25,10 @@ + + +

Required. We’ll email you when a plumber answers your question.

+ - - + + {{end}} diff --git a/templates/register.html b/templates/register.html index 432483d..a36a06e 100644 --- a/templates/register.html +++ b/templates/register.html @@ -9,6 +9,9 @@

3–20 letters, numbers, or underscores.

+ + +

We’ll email you when a plumber answers your question.

At least 8 characters (max 72 bytes).