Add edit RSVP modal, plus-one tracking, and unified signup form
Merge RSVP + slot claim into a single form. Add plus_one field to RSVPs. Add clickable RSVP names that open a modal to edit name/note/plus_one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ type Rsvp struct {
|
||||
EventID int64
|
||||
Name string
|
||||
Note string
|
||||
PlusOne int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -59,8 +59,8 @@ SELECT COUNT(*) FROM claims WHERE slot_id = ?;
|
||||
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
||||
|
||||
-- name: CreateRsvp :one
|
||||
INSERT INTO rsvps (event_id, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO rsvps (event_id, name, note, plus_one)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteRsvp :exec
|
||||
@@ -120,3 +120,9 @@ SELECT * FROM events WHERE user_id = ? ORDER BY created_at DESC;
|
||||
|
||||
-- name: SetEventUser :exec
|
||||
UPDATE events SET user_id = ? WHERE id = ?;
|
||||
|
||||
-- name: GetRsvp :one
|
||||
SELECT * FROM rsvps WHERE id = ?;
|
||||
|
||||
-- name: UpdateRsvp :exec
|
||||
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?;
|
||||
|
||||
+54
-6
@@ -101,25 +101,32 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
||||
}
|
||||
|
||||
const createRsvp = `-- name: CreateRsvp :one
|
||||
INSERT INTO rsvps (event_id, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, event_id, name, note, created_at
|
||||
INSERT INTO rsvps (event_id, name, note, plus_one)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, event_id, name, note, plus_one, created_at
|
||||
`
|
||||
|
||||
type CreateRsvpParams struct {
|
||||
EventID int64
|
||||
Name string
|
||||
Note string
|
||||
PlusOne int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
|
||||
row := q.db.QueryRowContext(ctx, createRsvp, arg.EventID, arg.Name, arg.Note)
|
||||
row := q.db.QueryRowContext(ctx, createRsvp,
|
||||
arg.EventID,
|
||||
arg.Name,
|
||||
arg.Note,
|
||||
arg.PlusOne,
|
||||
)
|
||||
var i Rsvp
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.PlusOne,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
@@ -332,8 +339,26 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRsvp = `-- name: GetRsvp :one
|
||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
||||
row := q.db.QueryRowContext(ctx, getRsvp, id)
|
||||
var i Rsvp
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.PlusOne,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRsvpByName = `-- name: GetRsvpByName :one
|
||||
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
||||
`
|
||||
|
||||
type GetRsvpByNameParams struct {
|
||||
@@ -349,6 +374,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.PlusOne,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
@@ -549,7 +575,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
|
||||
}
|
||||
|
||||
const listRsvps = `-- name: ListRsvps :many
|
||||
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
||||
`
|
||||
|
||||
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
||||
@@ -566,6 +592,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error)
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.PlusOne,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -675,6 +702,27 @@ func (q *Queries) UpdateEventDescription(ctx context.Context, arg UpdateEventDes
|
||||
return err
|
||||
}
|
||||
|
||||
const updateRsvp = `-- name: UpdateRsvp :exec
|
||||
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateRsvpParams struct {
|
||||
Name string
|
||||
Note string
|
||||
PlusOne int64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRsvp(ctx context.Context, arg UpdateRsvpParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateRsvp,
|
||||
arg.Name,
|
||||
arg.Note,
|
||||
arg.PlusOne,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSlot = `-- name: UpdateSlot :exec
|
||||
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
|
||||
`
|
||||
|
||||
+123
-76
@@ -136,6 +136,7 @@ type GoingPerson struct {
|
||||
Name string
|
||||
Note string
|
||||
RsvpID int64
|
||||
PlusOne int64
|
||||
}
|
||||
|
||||
type EventPageData struct {
|
||||
@@ -198,13 +199,15 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalGoing += int64(len(rsvps))
|
||||
for _, r := range rsvps {
|
||||
totalGoing += 1 + r.PlusOne
|
||||
}
|
||||
|
||||
// Build deduplicated GoingList: RSVPs first, then claim-only people
|
||||
var goingList []GoingPerson
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range rsvps {
|
||||
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID})
|
||||
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne})
|
||||
seen[strings.ToLower(r.Name)] = true
|
||||
}
|
||||
for _, c := range claims {
|
||||
@@ -258,78 +261,7 @@ func (s *Server) handleSlotsPartial(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- Claim / Unclaim ---
|
||||
|
||||
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||
r.ParseForm()
|
||||
|
||||
slotID, err := strconv.ParseInt(r.FormValue("slot_id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := sanitize(r.FormValue("name"), maxFieldLen)
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
|
||||
// Check slot exists and belongs to this event
|
||||
slot, err := s.q.GetSlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "Slot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil || slot.EventID != event.ID {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check not full
|
||||
count, err := s.q.CountClaimsBySlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= slot.MaxClaims {
|
||||
http.Error(w, "Slot is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{
|
||||
SlotID: slotID, Name: name, Note: note,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create claim: %v", err)
|
||||
http.Error(w, "Failed to claim", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-RSVP if they're not already on the going list
|
||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
// Return updated slots partial
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
// --- Unclaim ---
|
||||
|
||||
func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
@@ -355,7 +287,7 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- RSVP ---
|
||||
// --- RSVP (+ optional claim) ---
|
||||
|
||||
func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
@@ -368,6 +300,13 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
plusOne := int64(0)
|
||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||
plusOne = v
|
||||
}
|
||||
if plusOne > 10 {
|
||||
plusOne = 10
|
||||
}
|
||||
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
@@ -375,6 +314,49 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional slot claim
|
||||
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
||||
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
slot, err := s.q.GetSlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "Slot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if slot.EventID != event.ID {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := s.q.CountClaimsBySlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= slot.MaxClaims {
|
||||
http.Error(w, "Slot is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{
|
||||
SlotID: slotID, Name: name, Note: note,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create claim: %v", err)
|
||||
http.Error(w, "Failed to claim", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create RSVP (deduped — skip if already on the list)
|
||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
@@ -386,13 +368,14 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note,
|
||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create rsvp: %v", err)
|
||||
http.Error(w, "Failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
@@ -404,6 +387,70 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleEditRsvpForm(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid RSVP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rsvp, err := s.q.GetRsvp(r.Context(), rsvpID)
|
||||
if err != nil {
|
||||
http.Error(w, "RSVP not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pageTmpl["edit-rsvp"].ExecuteTemplate(w, "edit-rsvp", map[string]any{
|
||||
"Slug": slug,
|
||||
"Rsvp": rsvp,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid RSVP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||
r.ParseForm()
|
||||
|
||||
name := sanitize(r.FormValue("name"), maxFieldLen)
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
plusOne := int64(0)
|
||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||
plusOne = v
|
||||
}
|
||||
if plusOne > 10 {
|
||||
plusOne = 10
|
||||
}
|
||||
|
||||
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
||||
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
w.Header().Set("HX-Trigger", "closeModal")
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
|
||||
|
||||
+129
-63
@@ -34,39 +34,42 @@ func createTestEvent(t *testing.T, q *db.Queries) db.Event {
|
||||
return event
|
||||
}
|
||||
|
||||
func TestAutoRsvpOnClaim(t *testing.T) {
|
||||
// autoRsvp simulates the merged handleRsvp logic: create RSVP (deduped) + optional claim
|
||||
func autoRsvp(ctx context.Context, q *db.Queries, event db.Event, name string, note string, slotID *int64) error {
|
||||
return autoRsvpPlusOne(ctx, q, event, name, note, 0, slotID)
|
||||
}
|
||||
|
||||
func autoRsvpPlusOne(ctx context.Context, q *db.Queries, event db.Event, name string, note string, plusOne int64, slotID *int64) error {
|
||||
if slotID != nil {
|
||||
_, err := q.CreateClaim(ctx, db.CreateClaimParams{
|
||||
SlotID: *slotID, Name: name, Note: note,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRsvpOnly(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
|
||||
slot, err := q.CreateSlot(ctx, db.CreateSlotParams{
|
||||
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
|
||||
})
|
||||
if err != nil {
|
||||
if err := autoRsvp(ctx, q, event, "Alice", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Claim a slot — should also create an RSVP
|
||||
_, err = q.CreateClaim(ctx, db.CreateClaimParams{
|
||||
SlotID: slot.ID, Name: "Alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate the auto-RSVP logic from handleClaim
|
||||
_, err = q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: "Alice",
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "Alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 1 {
|
||||
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
|
||||
@@ -76,7 +79,42 @@ func TestAutoRsvpOnClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRsvpNoDuplicate(t *testing.T) {
|
||||
func TestRsvpWithClaim(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
|
||||
slot, err := q.CreateSlot(ctx, db.CreateSlotParams{
|
||||
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := autoRsvp(ctx, q, event, "Alice", "sparkling water", &slot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check RSVP created
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 1 {
|
||||
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
|
||||
}
|
||||
if rsvps[0].Name != "Alice" {
|
||||
t.Fatalf("expected RSVP name Alice, got %s", rsvps[0].Name)
|
||||
}
|
||||
|
||||
// Check claim created
|
||||
claims, _ := q.ListClaimsByEvent(ctx, event.ID)
|
||||
if len(claims) != 1 {
|
||||
t.Fatalf("expected 1 claim, got %d", len(claims))
|
||||
}
|
||||
if claims[0].Name != "Alice" {
|
||||
t.Fatalf("expected claim name Alice, got %s", claims[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvpNoDuplicate(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
@@ -85,23 +123,11 @@ func TestAutoRsvpNoDuplicate(t *testing.T) {
|
||||
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
|
||||
})
|
||||
|
||||
// RSVP first
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "Bob",
|
||||
})
|
||||
// RSVP first (no slot)
|
||||
autoRsvp(ctx, q, event, "Bob", "", nil)
|
||||
|
||||
// Then claim — auto-RSVP should skip
|
||||
q.CreateClaim(ctx, db.CreateClaimParams{
|
||||
SlotID: slot.ID, Name: "Bob",
|
||||
})
|
||||
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: "Bob",
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "Bob",
|
||||
})
|
||||
}
|
||||
// Then RSVP again with a slot claim — should not duplicate RSVP
|
||||
autoRsvp(ctx, q, event, "Bob", "", &slot.ID)
|
||||
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 1 {
|
||||
@@ -109,7 +135,7 @@ func TestAutoRsvpNoDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRsvpCaseInsensitive(t *testing.T) {
|
||||
func TestRsvpCaseInsensitiveDedup(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
@@ -119,22 +145,10 @@ func TestAutoRsvpCaseInsensitive(t *testing.T) {
|
||||
})
|
||||
|
||||
// RSVP as "alice"
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "alice",
|
||||
})
|
||||
autoRsvp(ctx, q, event, "alice", "", nil)
|
||||
|
||||
// Claim as "Alice" — should not create duplicate
|
||||
q.CreateClaim(ctx, db.CreateClaimParams{
|
||||
SlotID: slot.ID, Name: "Alice",
|
||||
})
|
||||
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: "Alice",
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "Alice",
|
||||
})
|
||||
}
|
||||
// Claim as "Alice" — should not create duplicate RSVP
|
||||
autoRsvp(ctx, q, event, "Alice", "", &slot.ID)
|
||||
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 1 {
|
||||
@@ -148,10 +162,10 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
|
||||
event := createTestEvent(t, q)
|
||||
|
||||
// Insert duplicate RSVPs directly
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie"})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana"})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0})
|
||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0})
|
||||
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 4 {
|
||||
@@ -166,3 +180,55 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
|
||||
t.Fatalf("expected 2 RSVPs after dedup (Charlie + Dana), got %d", len(rsvps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRsvp(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
|
||||
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
|
||||
Name: "Alicia", Note: "updated", PlusOne: 3, ID: rsvp.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := q.GetRsvp(ctx, rsvp.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Name != "Alicia" {
|
||||
t.Fatalf("expected name Alicia, got %s", updated.Name)
|
||||
}
|
||||
if updated.Note != "updated" {
|
||||
t.Fatalf("expected note 'updated', got %s", updated.Note)
|
||||
}
|
||||
if updated.PlusOne != 3 {
|
||||
t.Fatalf("expected PlusOne=3, got %d", updated.PlusOne)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvpPlusOne(t *testing.T) {
|
||||
_, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
event := createTestEvent(t, q)
|
||||
|
||||
if err := autoRsvpPlusOne(ctx, q, event, "Alice", "", 2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||
if len(rsvps) != 1 {
|
||||
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
|
||||
}
|
||||
if rsvps[0].PlusOne != 2 {
|
||||
t.Fatalf("expected PlusOne=2, got %d", rsvps[0].PlusOne)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ func main() {
|
||||
pageTmpl["slots"] = template.Must(
|
||||
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
|
||||
)
|
||||
// edit-rsvp modal partial
|
||||
pageTmpl["edit-rsvp"] = template.Must(
|
||||
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/edit-rsvp.html"),
|
||||
)
|
||||
|
||||
srv := NewServer(database, baseURL, features)
|
||||
|
||||
@@ -175,9 +179,10 @@ func main() {
|
||||
// Guest event view
|
||||
r.Get("/e/{slug}", srv.handleEvent)
|
||||
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
|
||||
r.Post("/e/{slug}/claim", srv.handleClaim)
|
||||
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
|
||||
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
|
||||
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
|
||||
r.Get("/e/{slug}/rsvp/{rsvpID}/edit", srv.handleEditRsvpForm)
|
||||
r.Put("/e/{slug}/rsvp/{rsvpID}", srv.handleUpdateRsvp)
|
||||
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
||||
|
||||
// OG image
|
||||
|
||||
@@ -20,6 +20,8 @@ func runMigrations(database *sql.DB) {
|
||||
`CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier)`,
|
||||
// Dedupe RSVPs: keep earliest per (event_id, name).
|
||||
`DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`,
|
||||
// Plus-one tracking for RSVPs.
|
||||
`ALTER TABLE rsvps ADD COLUMN plus_one INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, m := range migrations {
|
||||
_, err := database.Exec(m)
|
||||
|
||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
plus_one INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "edit-rsvp"}}
|
||||
<div class="modal-backdrop" onclick="if(event.target===this)document.getElementById('edit-modal').innerHTML=''">
|
||||
<div class="modal-card">
|
||||
<div class="form-title">Edit RSVP</div>
|
||||
<form hx-put="/e/{{.Slug}}/rsvp/{{.Rsvp.ID}}"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s">
|
||||
<div class="form-row">
|
||||
<label>Your name</label>
|
||||
<input type="text" name="name" value="{{.Rsvp.Name}}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bringing anyone?</label>
|
||||
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" name="note" value="{{.Rsvp.Note}}">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Save</button>
|
||||
<div style="text-align:center;margin-top:12px;">
|
||||
<button type="button" class="btn-cancel" onclick="document.getElementById('edit-modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
+12
-27
@@ -51,43 +51,28 @@
|
||||
<label>Your name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
|
||||
</div>
|
||||
{{if .Slots}}
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" name="note" placeholder="e.g. +1, arriving late, etc.">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .Slots}}
|
||||
<div class="section-label" style="margin-top:40px">I'll bring something</div>
|
||||
|
||||
<div class="claim-form-wrapper">
|
||||
<div class="form-title">Claim a slot →</div>
|
||||
<form hx-post="/e/{{.Event.Slug}}/claim"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||
<div class="form-row">
|
||||
<label>Your name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Slot</label>
|
||||
<select name="slot_id">
|
||||
<label>Bringing something?</label>
|
||||
<select name="slot_id" onchange="document.getElementById('claim-note').style.display = this.value ? '' : 'none'; document.getElementById('claim-note-input').placeholder = this.value ? 'e.g. bringing sparkling water + lemonade' : 'e.g. +1, arriving late, etc.'">
|
||||
<option value="">Just myself</option>
|
||||
{{range .Slots}}{{if not .IsFull}}
|
||||
<option value="{{.Slot.ID}}">{{.Slot.Emoji}} {{.Slot.Name}} ({{$left := sub .Slot.MaxClaims .ClaimCount}}{{$left}} spot{{if ne $left 1}}s{{end}} left)</option>
|
||||
{{end}}{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
|
||||
<label>Bringing anyone?</label>
|
||||
<input type="number" name="plus_one" value="0" min="0" max="10">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Claim ↗</button>
|
||||
<div class="form-row" id="claim-note">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="section-label" style="margin-top:40px">Admin: Description</div>
|
||||
|
||||
@@ -372,6 +372,40 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
.btn-danger:hover { color: #a00; }
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-card {
|
||||
background: white;
|
||||
border: var(--border-w) solid var(--border);
|
||||
box-shadow: 5px 5px 0 var(--ink);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
animation: fadeUp 0.2s ease both;
|
||||
}
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: #888;
|
||||
}
|
||||
.btn-cancel:hover { color: var(--ink); }
|
||||
.rsvp-name-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rsvp-name-link:hover { text-decoration: underline; }
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -438,6 +472,10 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', initEmojiPickers);
|
||||
document.body.addEventListener('closeModal', function() {
|
||||
document.getElementById('edit-modal').innerHTML = '';
|
||||
});
|
||||
</script>
|
||||
<div id="edit-modal"></div>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
{{if .GoingList}}
|
||||
{{range .GoingList}}
|
||||
<span class="claim-chip">
|
||||
{{.Name}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
|
||||
{{if gt .RsvpID 0}}<span class="rsvp-name-link"
|
||||
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
|
||||
hx-target="#edit-modal"
|
||||
hx-swap="innerHTML">{{.Name}}</span>{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
|
||||
{{if gt .RsvpID 0}}
|
||||
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
|
||||
hx-target="#slots-container"
|
||||
|
||||
Reference in New Issue
Block a user