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() }