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:
+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
|
||||
|
||||
Reference in New Issue
Block a user