* Implement attendance selection in RSVP form * Add admin endpoints for managing RSVPs and guests * Update RSVP handling to include attendance status
462 lines
11 KiB
Go
462 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rsvp_guests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rsvp_id INTEGER NOT NULL REFERENCES rsvps(id),
|
|
name TEXT NOT NULL,
|
|
is_child INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS photo_uploads (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
message TEXT,
|
|
email TEXT,
|
|
phone TEXT,
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS photo_files (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
upload_id INTEGER NOT NULL REFERENCES photo_uploads(id),
|
|
filename TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
`
|
|
|
|
func Open(dataDir string) (*Store, error) {
|
|
dbPath := filepath.Join(dataDir, "rsvp.db")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening database: %w", err)
|
|
}
|
|
// SQLite only supports one writer at a time; a single connection avoids
|
|
// SQLITE_BUSY errors under concurrent requests instead of retry logic.
|
|
db.SetMaxOpenConns(1)
|
|
|
|
if _, err := db.Exec(schema); err != nil {
|
|
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
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
type Guest struct {
|
|
ID int64
|
|
Name string
|
|
IsChild bool
|
|
}
|
|
|
|
type RSVP struct {
|
|
Guests []Guest
|
|
Message string
|
|
Attending bool
|
|
}
|
|
|
|
// ListGuests returns every RSVP'd guest, ordered by submission then entry order.
|
|
func (s *Store) ListGuests() ([]Guest, error) {
|
|
rows, err := s.db.Query(
|
|
`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 {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var guests []Guest
|
|
for rows.Next() {
|
|
var g Guest
|
|
if err := rows.Scan(&g.Name, &g.IsChild); err != nil {
|
|
return nil, err
|
|
}
|
|
guests = append(guests, g)
|
|
}
|
|
return guests, rows.Err()
|
|
}
|
|
|
|
type RSVPRecord struct {
|
|
ID int64
|
|
Message string
|
|
Attending bool
|
|
CreatedAt time.Time
|
|
Guests []Guest
|
|
}
|
|
|
|
// 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.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`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RSVPRecord
|
|
index := map[int64]int{}
|
|
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, &attending, &createdAt, &guestID, &name, &isChild); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
i, ok := index[id]
|
|
if !ok {
|
|
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{ID: guestID.Int64, Name: name.String, IsChild: isChild.Bool})
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteRSVP removes an RSVP submission and its guests.
|
|
func (s *Store) DeleteRSVP(id int64) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE rsvp_id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("rsvp %d not found", id)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
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 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
res, err := tx.Exec(
|
|
`INSERT INTO rsvps (message, attending, created_at) VALUES (?, ?, ?)`,
|
|
r.Message, r.Attending, time.Now().UTC(),
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rsvpID, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
for _, guest := range r.Guests {
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
|
|
rsvpID, guest.Name, guest.IsChild,
|
|
); err != nil {
|
|
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
|
|
URL string
|
|
}
|
|
|
|
type PhotoUpload struct {
|
|
Name string
|
|
Message string
|
|
Email string
|
|
Phone string
|
|
}
|
|
|
|
// CreatePhotoUpload inserts the upload record and returns its id, which the
|
|
// caller uses as the on-disk directory name before attaching files.
|
|
func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO photo_uploads (name, message, email, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
u.Name, u.Message, u.Email, u.Phone, time.Now().UTC(),
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
type PhotoFileRecord struct {
|
|
URL string
|
|
UploaderName string
|
|
}
|
|
|
|
// ListPhotoFiles returns every uploaded photo, most recent first.
|
|
func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT f.url, u.name
|
|
FROM photo_files f
|
|
JOIN photo_uploads u ON u.id = f.upload_id
|
|
ORDER BY f.created_at DESC, f.id DESC
|
|
LIMIT 200`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []PhotoFileRecord
|
|
for rows.Next() {
|
|
var rec PhotoFileRecord
|
|
if err := rows.Scan(&rec.URL, &rec.UploaderName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
type PhotoUploadRecord struct {
|
|
ID int64
|
|
Name string
|
|
Message string
|
|
CreatedAt time.Time
|
|
Files []PhotoFile
|
|
}
|
|
|
|
// ListPhotoUploads returns every upload submission (not flattened per-file), oldest first.
|
|
func (s *Store) ListPhotoUploads() ([]PhotoUploadRecord, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT u.id, u.name, u.message, u.created_at, f.filename, f.path, f.url
|
|
FROM photo_uploads u
|
|
LEFT JOIN photo_files f ON f.upload_id = u.id
|
|
ORDER BY u.created_at ASC, u.id ASC, f.id ASC`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []PhotoUploadRecord
|
|
index := map[int64]int{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var name, message string
|
|
var createdAt time.Time
|
|
var filename, path, url sql.NullString
|
|
if err := rows.Scan(&id, &name, &message, &createdAt, &filename, &path, &url); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
i, ok := index[id]
|
|
if !ok {
|
|
out = append(out, PhotoUploadRecord{ID: id, Name: name, Message: message, CreatedAt: createdAt})
|
|
i = len(out) - 1
|
|
index[id] = i
|
|
}
|
|
if filename.Valid {
|
|
out[i].Files = append(out[i].Files, PhotoFile{Filename: filename.String, Path: path.String, URL: url.String})
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeletePhotoUpload removes an upload's DB rows and returns the files it had,
|
|
// so the caller can also remove them from disk.
|
|
func (s *Store) DeletePhotoUpload(id int64) ([]PhotoFile, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
rows, err := tx.Query(`SELECT filename, path, url FROM photo_files WHERE upload_id = ?`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var files []PhotoFile
|
|
for rows.Next() {
|
|
var f PhotoFile
|
|
if err := rows.Scan(&f.Filename, &f.Path, &f.URL); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
files = append(files, f)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := tx.Exec(`DELETE FROM photo_files WHERE upload_id = ?`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := tx.Exec(`DELETE FROM photo_uploads WHERE id = ?`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n == 0 {
|
|
return nil, fmt.Errorf("photo upload %d not found", id)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now().UTC()
|
|
for _, f := range files {
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO photo_files (upload_id, filename, path, url, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
uploadID, f.Filename, f.Path, f.URL, now,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|