feat(webhook): add webhook client for RSVP and photo uploads

This commit is contained in:
2026-08-12 20:27:42 +02:00
parent df5d8efeb1
commit d8f70cf731
5 changed files with 136 additions and 6 deletions
+7
View File
@@ -26,3 +26,10 @@ email:
upload:
max_size_mb: 15
max_files: 10
webhook:
rsvp_url: "" # optional; POST JSON here on every new RSVP
photo_url: "" # optional; POST JSON here on every photo upload
secret: "" # optional; HMAC-SHA256-signs the payload, sent as X-Webhook-Signature
headers: {} # optional; extra headers sent with every webhook request, e.g. Authorization: "Bearer xxx"
timeout_seconds: 5
+10 -2
View File
@@ -17,16 +17,18 @@ import (
"wedding-server/internal/config"
"wedding-server/internal/mail"
"wedding-server/internal/store"
"wedding-server/internal/webhook"
)
type Server struct {
cfg *config.Config
store *store.Store
mailer *mail.Mailer
webhook *webhook.Client
}
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server {
return &Server{cfg: cfg, store: st, mailer: mailer}
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer, wh *webhook.Client) *Server {
return &Server{cfg: cfg, store: st, mailer: mailer, webhook: wh}
}
func (s *Server) Routes(mux *http.ServeMux) {
@@ -105,6 +107,9 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
if err := s.mailer.SendRSVP(rsvp); err != nil {
log.Printf("rsvp: email failed: %v", err)
}
if err := s.webhook.SendRSVP(rsvp); err != nil {
log.Printf("rsvp: webhook failed: %v", err)
}
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
@@ -275,6 +280,9 @@ func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
if err := s.mailer.SendPhotoUpload(upload, saved); err != nil {
log.Printf("photos: email failed: %v", err)
}
if err := s.webhook.SendPhotoUpload(upload, saved); err != nil {
log.Printf("photos: webhook failed: %v", err)
}
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
+15
View File
@@ -15,6 +15,7 @@ type Config struct {
Storage StorageConfig `yaml:"storage"`
Email EmailConfig `yaml:"email"`
Upload UploadConfig `yaml:"upload"`
Webhook WebhookConfig `yaml:"webhook"`
}
type ServerConfig struct {
@@ -52,6 +53,17 @@ type UploadConfig struct {
MaxFiles int `yaml:"max_files"`
}
// WebhookConfig is entirely optional: each URL is only called if set, so a
// deployment can wire up RSVP notifications without touching photo uploads
// (or either at all).
type WebhookConfig struct {
RSVPURL string `yaml:"rsvp_url"`
PhotoURL string `yaml:"photo_url"`
Secret string `yaml:"secret"` // signs the payload via HMAC-SHA256, sent in X-Webhook-Signature
Headers map[string]string `yaml:"headers"` // extra headers sent with every webhook request, e.g. Authorization
TimeoutSeconds int `yaml:"timeout_seconds"`
}
// Path resolves the config file location: $CONFIG_PATH, or ./config.yaml.
func Path() string {
if p := os.Getenv("CONFIG_PATH"); p != "" {
@@ -83,6 +95,9 @@ func Load(path string) (*Config, error) {
if cfg.Upload.MaxFiles == 0 {
cfg.Upload.MaxFiles = 10
}
if cfg.Webhook.TimeoutSeconds == 0 {
cfg.Webhook.TimeoutSeconds = 5
}
if cfg.Wedding.Date.IsZero() {
return nil, fmt.Errorf("wedding.date is required in %s", path)
}
+98
View File
@@ -0,0 +1,98 @@
package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"wedding-server/internal/config"
"wedding-server/internal/store"
)
type Client struct {
cfg config.WebhookConfig
httpClient *http.Client
}
func New(cfg config.WebhookConfig) *Client {
return &Client{
cfg: cfg,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second},
}
}
func (c *Client) post(url, event string, data any) error {
if url == "" {
return nil
}
body, err := json.Marshal(map[string]any{
"event": event,
"sentAt": time.Now().UTC(),
"data": data,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range c.cfg.Headers {
req.Header.Set(k, v)
}
if c.cfg.Secret != "" {
req.Header.Set("X-Webhook-Signature", sign(c.cfg.Secret, body))
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("webhook %s: unexpected status %d", url, resp.StatusCode)
}
return nil
}
func sign(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
func (c *Client) SendRSVP(r store.RSVP) error {
guests := make([]map[string]any, len(r.Guests))
for i, g := range r.Guests {
guests[i] = map[string]any{"name": g.Name, "isChild": g.IsChild}
}
return c.post(c.cfg.RSVPURL, "rsvp.created", map[string]any{
"guests": guests,
"message": r.Message,
})
}
func (c *Client) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
urls := make([]string, len(files))
for i, f := range files {
urls[i] = f.URL
}
return c.post(c.cfg.PhotoURL, "photos.uploaded", map[string]any{
"name": u.Name,
"message": u.Message,
"email": u.Email,
"phone": u.Phone,
"photoUrls": urls,
})
}
+3 -1
View File
@@ -14,6 +14,7 @@ import (
"wedding-server/internal/config"
"wedding-server/internal/mail"
"wedding-server/internal/store"
"wedding-server/internal/webhook"
)
//go:embed all:web/dist
@@ -37,9 +38,10 @@ func main() {
defer st.Close()
mailer := mail.New(cfg.Email)
wh := webhook.New(cfg.Webhook)
mux := http.NewServeMux()
api.New(cfg, st, mailer).Routes(mux)
api.New(cfg, st, mailer, wh).Routes(mux)
uploadsDir := filepath.Join(cfg.Storage.DataDir, "photos")
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))