Files
warkanum df5d8efeb1 feat(api): add endpoint to fetch guest list
* implement ListGuests method in store
* create handleGuestList function in API
* add GuestListEntry and GuestList interfaces in UI
* create guest list page to display RSVPs
2026-08-12 19:21:14 +02:00

180 lines
3.7 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,
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()
}