diff --git a/README.md b/README.md index 1a76501..0400a5d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ CONFIG_PATH=../config.yaml ./wedding-server # serves on :8080 ## Requirements - Countdown to the wedding date, with day/date/time and venue, on the landing page. - Landing page background: photos from `ui/assets/photos` slide in/out continuously behind the hero content. -- RSVP form: one or more guest names (add/remove rows, at least one required), message (optional) — no email/phone captured → saved + emailed to couple. Shows the RSVP deadline and its own countdown. +- RSVP form: Attending / Not-attending choice, one or more guest names (add/remove rows, at least one required), message (optional) — no email/phone captured → saved + emailed to couple. Shows the RSVP deadline and its own countdown. +- `/guests?admin=xxx`: unauthenticated admin editor — toggle attending, toggle adult/child, rename/remove guests, delete or add an RSVP. Gate is the URL flag only; `/api/admin/*` endpoints are unprotected. Non-attending RSVPs are excluded from the public guest list and counts. - Photo upload: name (required), message (optional), email/phone (optional) + photo files → saved to `data/photos/`, links emailed to couple. - Photo upload only opens **7 November 2026** (same date drives the countdown) — enforced server-side, not just hidden in the UI. - Venue, RSVP deadline, and RSVP phone number sourced from the formal invitation (`concept/IMG-20260811-WA0020.jpg`). diff --git a/server/cli.go b/server/cli.go index 2d39c26..81f0abb 100644 --- a/server/cli.go +++ b/server/cli.go @@ -69,7 +69,11 @@ func runRSVPCLI(st *store.Store, args []string) int { names[i] += " (child)" } } - fmt.Printf("#%d %s %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), strings.Join(names, ", ")) + state := "attending" + if !r.Attending { + state = "NOT attending" + } + fmt.Printf("#%d %s [%s] %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), state, strings.Join(names, ", ")) if r.Message != "" { fmt.Printf(" message: %s\n", r.Message) } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index c39ee21..ba18c29 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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) diff --git a/server/internal/mail/mail.go b/server/internal/mail/mail.go index a12e6c7..d9befce 100644 --- a/server/internal/mail/mail.go +++ b/server/internal/mail/mail.go @@ -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 { diff --git a/server/internal/store/store.go b/server/internal/store/store.go index c5e451c..0898461 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -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 diff --git a/server/internal/webhook/webhook.go b/server/internal/webhook/webhook.go index 6136f5a..e1a7d7e 100644 --- a/server/internal/webhook/webhook.go +++ b/server/internal/webhook/webhook.go @@ -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, }) } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index a03569f..4450574 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -19,6 +19,7 @@ export interface Guest { export interface RsvpPayload { guests: Guest[]; message?: string; + attending: boolean; } export interface GuestPhoto { @@ -80,6 +81,60 @@ export async function submitRsvp(payload: RsvpPayload): Promise { if (!response.ok) throw new ApiError(await parseError(response), response.status); } +export interface AdminGuest { + id: number; + name: string; + isChild: boolean; +} + +export interface AdminRsvp { + id: number; + attending: boolean; + message: string; + createdAt: string; + guests: AdminGuest[]; +} + +async function jsonRequest(url: string, method: string, body?: unknown): Promise { + const response = await fetch(url, { + method, + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + if (!response.ok) throw new ApiError(await parseError(response), response.status); + return response.status === 204 ? (undefined as T) : response.json(); +} + +export async function fetchAdminRsvps(fetchImpl: typeof fetch = fetch): Promise { + const response = await fetchImpl('/api/admin/rsvps'); + if (!response.ok) throw new ApiError(await parseError(response), response.status); + return (await response.json()).rsvps ?? []; +} + +export function setRsvpAttending(id: number, attending: boolean): Promise { + return jsonRequest(`/api/admin/rsvps/${id}`, 'PATCH', { attending }); +} + +export function deleteRsvp(id: number): Promise { + return jsonRequest(`/api/admin/rsvps/${id}`, 'DELETE'); +} + +export function addAdminGuest(rsvpId: number, guest: Guest): Promise<{ id: number }> { + return jsonRequest(`/api/admin/rsvps/${rsvpId}/guests`, 'POST', guest); +} + +export function updateAdminGuest(id: number, guest: Guest): Promise { + return jsonRequest(`/api/admin/guests/${id}`, 'PATCH', guest); +} + +export function deleteAdminGuest(id: number): Promise { + return jsonRequest(`/api/admin/guests/${id}`, 'DELETE'); +} + +export function createAdminRsvp(payload: RsvpPayload): Promise<{ id: number }> { + return jsonRequest('/api/admin/rsvps', 'POST', payload); +} + export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise { const response = await fetchImpl('/api/guests'); if (!response.ok) throw new ApiError(await parseError(response), response.status); diff --git a/ui/src/routes/guests/+page.svelte b/ui/src/routes/guests/+page.svelte index 445d9af..8fa7f51 100644 --- a/ui/src/routes/guests/+page.svelte +++ b/ui/src/routes/guests/+page.svelte @@ -1,57 +1,244 @@
-

Who's coming

-

- {#if status === 'done'} +

Who's coming{isAdmin ? ' — admin' : ''}

+ + {#if status === 'loading'} +

Loading…

+ {:else if status === 'error'} +

{errorMessage}

+ {:else if isAdmin} + {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ {#each rsvps as r (r.id)} +
+
+ + +
+ + {#each r.guests as g (g.id)} +
+ renameGuest(g, e.currentTarget.value)} + /> + + +
+ {/each} + +
{ + e.preventDefault(); + const f = e.currentTarget; + const input = f.elements.namedItem('name') as HTMLInputElement; + const child = f.elements.namedItem('child') as HTMLInputElement; + addGuest(r.id, input.value, child.checked); + input.value = ''; + child.checked = false; + }} + > + + + +
+
+ {/each} + +
+ Add RSVP + +
+ + +
+ +
+
+ {:else} +

{guestList.total} {guestList.total === 1 ? 'guest has' : 'guests have'} RSVP'd {#if guestList.children > 0} — {guestList.adults} adults, {guestList.children} children {/if} - {:else} - Everyone who has RSVP'd so far. - {/if} -

+

- {#if status === 'loading'} -

Loading…

- {:else if status === 'error'} -

{errorMessage}

- {:else if guestList.total === 0} -

No RSVPs yet.

- {:else} -
    - {#each guestList.guests as guest, i (i)} -
  • - {guest.name} - {#if guest.isChild} - Child - {/if} -
  • - {/each} -
+ {#if guestList.total === 0} +

No RSVPs yet.

+ {:else} +
    + {#each guestList.guests as guest, i (i)} +
  • + {guest.name} + {#if guest.isChild} + Child + {/if} +
  • + {/each} +
+ {/if} {/if}
diff --git a/ui/src/routes/rsvp/+page.svelte b/ui/src/routes/rsvp/+page.svelte index bf092c3..6fe80f0 100644 --- a/ui/src/routes/rsvp/+page.svelte +++ b/ui/src/routes/rsvp/+page.svelte @@ -17,6 +17,7 @@ let guests = $state([{ name: "", type: "adult" }]); let message = $state(""); + let attending = $state(true); let status = $state<"idle" | "submitting" | "done" | "error">("idle"); let errorMessage = $state(""); @@ -46,6 +47,7 @@ await submitRsvp({ guests: trimmedGuests, message: message || undefined, + attending, }); status = "done"; rsvpStatus.markSubmitted(); @@ -174,6 +176,34 @@

{errorMessage}

{/if} +
+ Will you attend? * +
+ + +
+
+