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}
|
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 != "" {
|
if bcc = strings.TrimSpace(bcc); bcc != "" {
|
||||||
recipients = append(recipients, bcc)
|
recipients = append(recipients, bcc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,76 @@ func (s *Store) ListGuests() ([]Guest, error) {
|
|||||||
return guests, rows.Err()
|
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 {
|
func (s *Store) InsertRSVP(r RSVP) error {
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,6 +259,98 @@ func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
|
|||||||
return out, rows.Err()
|
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 {
|
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+13
-3
@@ -37,6 +37,12 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("store: %v", err)
|
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()
|
defer st.Close()
|
||||||
|
|
||||||
mailer := mail.New(cfg.Email)
|
mailer := mail.New(cfg.Email)
|
||||||
@@ -85,12 +91,16 @@ func main() {
|
|||||||
func staticHandler(fsys fs.FS) http.Handler {
|
func staticHandler(fsys fs.FS) http.Handler {
|
||||||
fileServer := http.FileServer(http.FS(fsys))
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 == "" {
|
if p == "" {
|
||||||
p = "index.html"
|
p = "index.html"
|
||||||
}
|
}
|
||||||
if _, err := fs.Stat(fsys, p); err != nil {
|
// A route name (e.g. "photos") can collide with a static asset
|
||||||
if _, err := fs.Stat(fsys, p+".html"); err == nil {
|
// 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 := *r
|
||||||
r2.URL.Path = "/" + p + ".html"
|
r2.URL.Path = "/" + p + ".html"
|
||||||
fileServer.ServeHTTP(w, &r2)
|
fileServer.ServeHTTP(w, &r2)
|
||||||
|
|||||||
Reference in New Issue
Block a user