feat(cli): add CLI for managing RSVPs and photo uploads

* implement runCLI function to handle subcommands
* add runRSVPCLI and runPhotosCLI for RSVP and photo management
* include usage instructions for CLI commands
* integrate CLI execution in main function
This commit is contained in:
2026-08-23 11:45:48 +02:00
parent 79e4c11a16
commit f96465b798
4 changed files with 347 additions and 4 deletions
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"wedding-server/internal/store"
)
// runCLI handles admin subcommands (listing/removing bad RSVPs or photo
// uploads) so fixing a mistake doesn't require touching the database by hand.
// Returns the process exit code.
func runCLI(dataDir string, st *store.Store, args []string) int {
if len(args) == 0 {
printCLIUsage()
return 1
}
switch args[0] {
case "rsvp":
return runRSVPCLI(st, args[1:])
case "photos":
return runPhotosCLI(dataDir, st, args[1:])
case "help", "-h", "--help":
printCLIUsage()
return 0
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0])
printCLIUsage()
return 1
}
}
func printCLIUsage() {
fmt.Println(`Usage:
wedding-server start the web server
wedding-server rsvp list list all RSVPs
wedding-server rsvp delete <id> delete an RSVP and its guests
wedding-server photos list list all photo uploads
wedding-server photos delete <id> delete a photo upload and its files`)
}
func runRSVPCLI(st *store.Store, args []string) int {
if len(args) == 0 {
printCLIUsage()
return 1
}
switch args[0] {
case "list":
rsvps, err := st.ListRSVPs()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
if len(rsvps) == 0 {
fmt.Println("no RSVPs yet")
return 0
}
for _, r := range rsvps {
names := make([]string, len(r.Guests))
for i, g := range r.Guests {
names[i] = g.Name
if g.IsChild {
names[i] += " (child)"
}
}
fmt.Printf("#%d %s %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), strings.Join(names, ", "))
if r.Message != "" {
fmt.Printf(" message: %s\n", r.Message)
}
}
return 0
case "delete":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: wedding-server rsvp delete <id>")
return 1
}
id, err := strconv.ParseInt(args[1], 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
return 1
}
if !confirm(fmt.Sprintf("Delete RSVP #%d? This cannot be undone.", id)) {
fmt.Println("cancelled")
return 0
}
if err := st.DeleteRSVP(id); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
fmt.Printf("deleted RSVP #%d\n", id)
return 0
default:
printCLIUsage()
return 1
}
}
func runPhotosCLI(dataDir string, st *store.Store, args []string) int {
if len(args) == 0 {
printCLIUsage()
return 1
}
switch args[0] {
case "list":
uploads, err := st.ListPhotoUploads()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
if len(uploads) == 0 {
fmt.Println("no photo uploads yet")
return 0
}
for _, u := range uploads {
fmt.Printf("#%d %s %s (%d photo(s))\n", u.ID, u.CreatedAt.Local().Format("2006-01-02 15:04"), u.Name, len(u.Files))
for _, f := range u.Files {
fmt.Printf(" %s\n", f.URL)
}
}
return 0
case "delete":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: wedding-server photos delete <id>")
return 1
}
id, err := strconv.ParseInt(args[1], 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
return 1
}
if !confirm(fmt.Sprintf("Delete photo upload #%d and its files? This cannot be undone.", id)) {
fmt.Println("cancelled")
return 0
}
files, err := st.DeletePhotoUpload(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
for _, f := range files {
if err := os.Remove(f.Path); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: could not remove %s: %v\n", f.Path, err)
}
}
// Best-effort: only removes the per-upload directory if now empty.
_ = os.Remove(filepath.Join(dataDir, "photos", strconv.FormatInt(id, 10)))
fmt.Printf("deleted photo upload #%d (%d file(s))\n", id, len(files))
return 0
default:
printCLIUsage()
return 1
}
}
func confirm(prompt string) bool {
fmt.Printf("%s [y/N]: ", prompt)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (m *Mailer) send(subject, body string) error {
)
recipients := []string{m.cfg.To}
for _, bcc := range strings.Split(m.cfg.Bcc, ",") {
for bcc := range strings.SplitSeq(m.cfg.Bcc, ",") {
if bcc = strings.TrimSpace(bcc); bcc != "" {
recipients = append(recipients, bcc)
}
+162
View File
@@ -102,6 +102,76 @@ func (s *Store) ListGuests() ([]Guest, error) {
return guests, rows.Err()
}
type RSVPRecord struct {
ID int64
Message string
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.created_at, 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 createdAt time.Time
var name sql.NullString
var isChild sql.NullBool
if err := rows.Scan(&id, &message, &createdAt, &name, &isChild); err != nil {
return nil, err
}
i, ok := index[id]
if !ok {
out = append(out, RSVPRecord{ID: id, Message: message, 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})
}
}
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 {
tx, err := s.db.Begin()
if err != nil {
@@ -189,6 +259,98 @@ func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
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 {
+13 -3
View File
@@ -37,6 +37,12 @@ func main() {
if err != nil {
log.Fatalf("store: %v", err)
}
if len(os.Args) > 1 {
code := runCLI(cfg.Storage.DataDir, st, os.Args[1:])
st.Close()
os.Exit(code)
}
defer st.Close()
mailer := mail.New(cfg.Email)
@@ -85,12 +91,16 @@ func main() {
func staticHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
p := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(fsys, p); err != nil {
if _, err := fs.Stat(fsys, p+".html"); err == nil {
// A route name (e.g. "photos") can collide with a static asset
// directory of the same name; prefer the prerendered page in
// that case instead of falling through to a directory listing.
info, statErr := fs.Stat(fsys, p)
if statErr != nil || info.IsDir() {
if hinfo, err := fs.Stat(fsys, p+".html"); err == nil && !hinfo.IsDir() {
r2 := *r
r2.URL.Path = "/" + p + ".html"
fileServer.ServeHTTP(w, &r2)