feat(ui): add RSVP attendance option and admin features
* Implement attendance selection in RSVP form * Add admin endpoints for managing RSVPs and guests * Update RSVP handling to include attendance status
This commit is contained in:
@@ -38,6 +38,186 @@ func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/guests", s.handleGuestList)
|
||||
mux.HandleFunc("GET /api/photos", s.handleListPhotos)
|
||||
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
|
||||
|
||||
// Admin RSVP editing. These endpoints are unauthenticated; the UI gates
|
||||
// them behind ?admin=1 only. Put the server behind auth before exposing it
|
||||
// on an untrusted network.
|
||||
mux.HandleFunc("GET /api/admin/rsvps", s.handleAdminListRSVPs)
|
||||
mux.HandleFunc("POST /api/admin/rsvps", s.handleAdminCreateRSVP)
|
||||
mux.HandleFunc("PATCH /api/admin/rsvps/{id}", s.handleAdminPatchRSVP)
|
||||
mux.HandleFunc("DELETE /api/admin/rsvps/{id}", s.handleAdminDeleteRSVP)
|
||||
mux.HandleFunc("POST /api/admin/rsvps/{id}/guests", s.handleAdminAddGuest)
|
||||
mux.HandleFunc("PATCH /api/admin/guests/{id}", s.handleAdminPatchGuest)
|
||||
mux.HandleFunc("DELETE /api/admin/guests/{id}", s.handleAdminDeleteGuest)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
type adminGuest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsChild bool `json:"isChild"`
|
||||
}
|
||||
|
||||
type adminRSVP struct {
|
||||
ID int64 `json:"id"`
|
||||
Attending bool `json:"attending"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Guests []adminGuest `json:"guests"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListRSVPs(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := s.store.ListRSVPs()
|
||||
if err != nil {
|
||||
log.Printf("admin: list rsvps failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load RSVPs")
|
||||
return
|
||||
}
|
||||
out := make([]adminRSVP, 0, len(records))
|
||||
for _, rec := range records {
|
||||
guests := make([]adminGuest, 0, len(rec.Guests))
|
||||
for _, g := range rec.Guests {
|
||||
guests = append(guests, adminGuest{ID: g.ID, Name: g.Name, IsChild: g.IsChild})
|
||||
}
|
||||
out = append(out, adminRSVP{
|
||||
ID: rec.ID,
|
||||
Attending: rec.Attending,
|
||||
Message: rec.Message,
|
||||
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
|
||||
Guests: guests,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rsvps": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
var req rsvpRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
var guests []store.Guest
|
||||
for _, g := range req.Guests {
|
||||
name := strings.TrimSpace(g.Name)
|
||||
if name != "" {
|
||||
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
|
||||
}
|
||||
}
|
||||
if len(guests) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one name is required")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateRSVP(store.RSVP{
|
||||
Guests: guests,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
Attending: req.Attending == nil || *req.Attending,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("admin: create rsvp failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save RSVP")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPatchRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Attending *bool `json:"attending"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.Attending == nil {
|
||||
writeError(w, http.StatusBadRequest, "attending is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetRSVPAttending(id, *req.Attending); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteRSVP(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAddGuest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req guestRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
guestID, err := s.store.AddGuest(id, store.Guest{Name: name, IsChild: req.Type == "child"})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": guestID})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPatchGuest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var req guestRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateGuest(id, name, req.Type == "child"); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteGuest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteGuest(id); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
@@ -73,8 +253,9 @@ type guestRequest struct {
|
||||
}
|
||||
|
||||
type rsvpRequest struct {
|
||||
Guests []guestRequest `json:"guests"`
|
||||
Message string `json:"message"`
|
||||
Guests []guestRequest `json:"guests"`
|
||||
Message string `json:"message"`
|
||||
Attending *bool `json:"attending"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -96,9 +277,11 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
attending := req.Attending == nil || *req.Attending
|
||||
rsvp := store.RSVP{
|
||||
Guests: guests,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
Guests: guests,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
Attending: attending,
|
||||
}
|
||||
if err := s.store.InsertRSVP(rsvp); err != nil {
|
||||
log.Printf("rsvp: insert failed: %v", err)
|
||||
|
||||
@@ -93,7 +93,11 @@ func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
|
||||
joined := strings.Join(names, ", ")
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "New RSVP for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
|
||||
if r.Attending {
|
||||
fmt.Fprintf(&b, "New RSVP (ATTENDING) for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "New RSVP (NOT ATTENDING) — %s\n", joined)
|
||||
}
|
||||
if r.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
|
||||
}
|
||||
@@ -113,7 +117,11 @@ func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
|
||||
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
|
||||
}
|
||||
|
||||
return m.send(fmt.Sprintf("RSVP from %s", joined), m.header("an RSVP is made")+b.String())
|
||||
subjectState := "attending"
|
||||
if !r.Attending {
|
||||
subjectState = "not attending"
|
||||
}
|
||||
return m.send(fmt.Sprintf("RSVP from %s (%s)", joined, subjectState), m.header("an RSVP is made")+b.String())
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
|
||||
|
||||
+102
-12
@@ -17,6 +17,7 @@ const schema = `
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT,
|
||||
attending INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
@@ -60,6 +61,9 @@ func Open(dataDir string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating schema: %w", err)
|
||||
}
|
||||
// Backfill columns on databases created before they existed; SQLite has no
|
||||
// "ADD COLUMN IF NOT EXISTS", so a duplicate-column error here is expected.
|
||||
db.Exec(`ALTER TABLE rsvps ADD COLUMN attending INTEGER NOT NULL DEFAULT 1`)
|
||||
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
@@ -69,13 +73,15 @@ func (s *Store) Close() error {
|
||||
}
|
||||
|
||||
type Guest struct {
|
||||
ID int64
|
||||
Name string
|
||||
IsChild bool
|
||||
}
|
||||
|
||||
type RSVP struct {
|
||||
Guests []Guest
|
||||
Message string
|
||||
Guests []Guest
|
||||
Message string
|
||||
Attending bool
|
||||
}
|
||||
|
||||
// ListGuests returns every RSVP'd guest, ordered by submission then entry order.
|
||||
@@ -84,6 +90,7 @@ func (s *Store) ListGuests() ([]Guest, error) {
|
||||
`SELECT g.name, g.is_child
|
||||
FROM rsvp_guests g
|
||||
JOIN rsvps r ON r.id = g.rsvp_id
|
||||
WHERE r.attending <> 0
|
||||
ORDER BY r.created_at ASC, g.id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -105,6 +112,7 @@ func (s *Store) ListGuests() ([]Guest, error) {
|
||||
type RSVPRecord struct {
|
||||
ID int64
|
||||
Message string
|
||||
Attending bool
|
||||
CreatedAt time.Time
|
||||
Guests []Guest
|
||||
}
|
||||
@@ -112,7 +120,7 @@ type RSVPRecord struct {
|
||||
// ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first.
|
||||
func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT r.id, r.message, r.created_at, g.name, g.is_child
|
||||
`SELECT r.id, r.message, r.attending, r.created_at, g.id, g.name, g.is_child
|
||||
FROM rsvps r
|
||||
LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id
|
||||
ORDER BY r.created_at ASC, r.id ASC, g.id ASC`,
|
||||
@@ -127,21 +135,23 @@ func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var message string
|
||||
var attending bool
|
||||
var createdAt time.Time
|
||||
var guestID sql.NullInt64
|
||||
var name sql.NullString
|
||||
var isChild sql.NullBool
|
||||
if err := rows.Scan(&id, &message, &createdAt, &name, &isChild); err != nil {
|
||||
if err := rows.Scan(&id, &message, &attending, &createdAt, &guestID, &name, &isChild); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i, ok := index[id]
|
||||
if !ok {
|
||||
out = append(out, RSVPRecord{ID: id, Message: message, CreatedAt: createdAt})
|
||||
out = append(out, RSVPRecord{ID: id, Message: message, Attending: attending, CreatedAt: createdAt})
|
||||
i = len(out) - 1
|
||||
index[id] = i
|
||||
}
|
||||
if name.Valid {
|
||||
out[i].Guests = append(out[i].Guests, Guest{Name: name.String, IsChild: isChild.Bool})
|
||||
out[i].Guests = append(out[i].Guests, Guest{ID: guestID.Int64, Name: name.String, IsChild: isChild.Bool})
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -173,22 +183,28 @@ func (s *Store) DeleteRSVP(id int64) error {
|
||||
}
|
||||
|
||||
func (s *Store) InsertRSVP(r RSVP) error {
|
||||
_, err := s.CreateRSVP(r)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateRSVP inserts an RSVP and its guests, returning the new RSVP id.
|
||||
func (s *Store) CreateRSVP(r RSVP) (int64, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO rsvps (message, created_at) VALUES (?, ?)`,
|
||||
r.Message, time.Now().UTC(),
|
||||
`INSERT INTO rsvps (message, attending, created_at) VALUES (?, ?, ?)`,
|
||||
r.Message, r.Attending, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
rsvpID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, guest := range r.Guests {
|
||||
@@ -196,13 +212,87 @@ func (s *Store) InsertRSVP(r RSVP) error {
|
||||
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
|
||||
rsvpID, guest.Name, guest.IsChild,
|
||||
); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return rsvpID, tx.Commit()
|
||||
}
|
||||
|
||||
// SetRSVPAttending flips the attendance flag on one RSVP.
|
||||
func (s *Store) SetRSVPAttending(id int64, attending bool) error {
|
||||
return s.mustAffectOne(`UPDATE rsvps SET attending = ? WHERE id = ?`, "rsvp", id, attending, id)
|
||||
}
|
||||
|
||||
// UpdateGuest renames a guest line and sets its child flag.
|
||||
func (s *Store) UpdateGuest(id int64, name string, isChild bool) error {
|
||||
return s.mustAffectOne(`UPDATE rsvp_guests SET name = ?, is_child = ? WHERE id = ?`, "guest", id, name, isChild, id)
|
||||
}
|
||||
|
||||
// AddGuest appends a guest to an existing RSVP and returns the new guest id.
|
||||
func (s *Store) AddGuest(rsvpID int64, g Guest) (int64, error) {
|
||||
var exists int
|
||||
if err := s.db.QueryRow(`SELECT 1 FROM rsvps WHERE id = ?`, rsvpID).Scan(&exists); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, fmt.Errorf("rsvp %d not found", rsvpID)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
|
||||
rsvpID, g.Name, g.IsChild,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteGuest removes one guest line, and its parent RSVP if it was the last one.
|
||||
func (s *Store) DeleteGuest(id int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var rsvpID int64
|
||||
if err := tx.QueryRow(`SELECT rsvp_id FROM rsvp_guests WHERE id = ?`, id).Scan(&rsvpID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("guest %d not found", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
var remaining int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM rsvp_guests WHERE rsvp_id = ?`, rsvpID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining == 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, rsvpID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) mustAffectOne(query, label string, id int64, args ...any) error {
|
||||
res, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%s %d not found", label, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PhotoFile struct {
|
||||
Filename string
|
||||
Path string
|
||||
|
||||
@@ -78,8 +78,9 @@ func (c *Client) SendRSVP(r store.RSVP) error {
|
||||
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,
|
||||
"guests": guests,
|
||||
"message": r.Message,
|
||||
"attending": r.Attending,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user