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, 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) } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } type Guest struct { Name string IsChild bool } type RSVP struct { Guests []Guest Message string } // 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 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() } func (s *Store) InsertRSVP(r RSVP) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() res, err := tx.Exec( `INSERT INTO rsvps (message, created_at) VALUES (?, ?)`, r.Message, time.Now().UTC(), ) if err != nil { return err } rsvpID, err := res.LastInsertId() if err != nil { return 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 err } } return tx.Commit() } 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() } 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() }