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:
+171
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user