Compare commits
6 Commits
780a214c7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 891b49b4c0 | |||
| 1f0effb902 | |||
| d005080aca | |||
| 2aa3abf035 | |||
| 426df48901 | |||
| 4915af0665 |
@@ -2,3 +2,6 @@ bbq
|
|||||||
bbq.db
|
bbq.db
|
||||||
bbq.db-shm
|
bbq.db-shm
|
||||||
bbq.db-wal
|
bbq.db-wal
|
||||||
|
test.db
|
||||||
|
test.db-shm
|
||||||
|
test.db-wal
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ COPY . .
|
|||||||
RUN CGO_ENABLED=1 go build -o /bbq .
|
RUN CGO_ENABLED=1 go build -o /bbq .
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /bbq /usr/local/bin/bbq
|
COPY --from=build /bbq /usr/local/bin/bbq
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
ENV BBQ_DB=/data/bbq.db
|
ENV BBQ_DB=/data/bbq.db
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -63,49 +64,76 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
|
|||||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.currentUser(r) == nil {
|
if s.currentUser(r) == nil {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
dest := "/login"
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
dest += "?next=" + url.QueryEscape(r.URL.RequestURI())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeNext returns next if it is a safe local path, else "".
|
||||||
|
func safeNext(next string) string {
|
||||||
|
if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") && !strings.ContainsAny(next, "\\\r\n") {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// isEmail returns true if the input looks like an email address.
|
// isEmail returns true if the input looks like an email address.
|
||||||
func isEmail(s string) bool {
|
func isEmail(s string) bool {
|
||||||
return strings.Contains(s, "@")
|
return strings.Contains(s, "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := safeNext(r.URL.Query().Get("next"))
|
||||||
if s.currentUser(r) != nil {
|
if s.currentUser(r) != nil {
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
dest := "/dashboard"
|
||||||
|
if next != "" {
|
||||||
|
dest = next
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
||||||
"Step": "identify",
|
"Step": "identify",
|
||||||
|
"Next": next,
|
||||||
"AuthEnabled": true,
|
"AuthEnabled": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := safeNext(r.FormValue("next"))
|
||||||
|
loginURL := "/login"
|
||||||
|
if next != "" {
|
||||||
|
loginURL += "?next=" + url.QueryEscape(next)
|
||||||
|
}
|
||||||
raw := strings.TrimSpace(r.FormValue("identifier"))
|
raw := strings.TrimSpace(r.FormValue("identifier"))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, loginURL, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
identifier := strings.ToLower(raw)
|
identifier := strings.ToLower(raw)
|
||||||
if !isEmail(identifier) {
|
if !isEmail(identifier) {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, loginURL, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := generateCode()
|
code := generateCode()
|
||||||
expiresAt := time.Now().UTC().Add(10 * time.Minute)
|
expiresAt := time.Now().UTC().Add(10 * time.Minute)
|
||||||
s.q.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
|
if err := s.q.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
|
||||||
Identifier: identifier,
|
Identifier: identifier,
|
||||||
Code: code,
|
Code: code,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
})
|
}); err != nil {
|
||||||
|
log.Printf("failed to store verification code for %s: %v", identifier, err)
|
||||||
|
http.Error(w, "Internal error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := sendVerificationEmail(identifier, code); err != nil {
|
if err := sendVerificationEmail(identifier, code); err != nil {
|
||||||
log.Printf("failed to send verification email to %s: %v", identifier, err)
|
log.Printf("failed to send verification email to %s: %v", identifier, err)
|
||||||
@@ -114,11 +142,13 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
||||||
"Step": "code",
|
"Step": "code",
|
||||||
"Identifier": identifier,
|
"Identifier": identifier,
|
||||||
|
"Next": next,
|
||||||
"AuthEnabled": true,
|
"AuthEnabled": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next := safeNext(r.FormValue("next"))
|
||||||
identifier := strings.TrimSpace(r.FormValue("identifier"))
|
identifier := strings.TrimSpace(r.FormValue("identifier"))
|
||||||
code := strings.TrimSpace(r.FormValue("code"))
|
code := strings.TrimSpace(r.FormValue("code"))
|
||||||
|
|
||||||
@@ -130,6 +160,7 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
|
||||||
"Step": "code",
|
"Step": "code",
|
||||||
"Identifier": identifier,
|
"Identifier": identifier,
|
||||||
|
"Next": next,
|
||||||
"Error": "Invalid or expired code. Try again.",
|
"Error": "Invalid or expired code. Try again.",
|
||||||
"AuthEnabled": true,
|
"AuthEnabled": true,
|
||||||
})
|
})
|
||||||
@@ -170,10 +201,18 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// If user has no name, send them to set it
|
// If user has no name, send them to set it
|
||||||
if user.Name == "" {
|
if user.Name == "" {
|
||||||
http.Redirect(w, r, "/account/name", http.StatusSeeOther)
|
dest := "/account/name"
|
||||||
|
if next != "" {
|
||||||
|
dest += "?next=" + url.QueryEscape(next)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if next != "" {
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,21 +250,31 @@ func (s *Server) handleNamePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := s.currentUser(r)
|
user := s.currentUser(r)
|
||||||
pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{
|
pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{
|
||||||
"User": user,
|
"User": user,
|
||||||
|
"Next": safeNext(r.URL.Query().Get("next")),
|
||||||
"AuthEnabled": true,
|
"AuthEnabled": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
user := s.currentUser(r)
|
user := s.currentUser(r)
|
||||||
|
next := safeNext(r.FormValue("next"))
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Redirect(w, r, "/account/name", http.StatusSeeOther)
|
dest := "/account/name"
|
||||||
|
if next != "" {
|
||||||
|
dest += "?next=" + url.QueryEscape(next)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{
|
s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{
|
||||||
Name: name,
|
Name: name,
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
})
|
})
|
||||||
|
if next != "" {
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Event struct {
|
|||||||
Location string
|
Location string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
Description string
|
Description string
|
||||||
|
AttendeeCap int64
|
||||||
UserID sql.NullInt64
|
UserID sql.NullInt64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ type Rsvp struct {
|
|||||||
Name string
|
Name string
|
||||||
Note string
|
Note string
|
||||||
PlusOne int64
|
PlusOne int64
|
||||||
|
UserID sql.NullInt64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -5,8 +5,8 @@ SELECT * FROM events WHERE slug = ?;
|
|||||||
SELECT * FROM events WHERE admin_token = ?;
|
SELECT * FROM events WHERE admin_token = ?;
|
||||||
|
|
||||||
-- name: CreateEvent :one
|
-- name: CreateEvent :one
|
||||||
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateEventDescription :exec
|
-- name: UpdateEventDescription :exec
|
||||||
@@ -18,6 +18,9 @@ UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
|
|||||||
-- name: UpdateEventLocation :exec
|
-- name: UpdateEventLocation :exec
|
||||||
UPDATE events SET location = ? WHERE id = ?;
|
UPDATE events SET location = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: UpdateEventDateTime :exec
|
||||||
|
UPDATE events SET date = ?, time = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteEvent :exec
|
-- name: DeleteEvent :exec
|
||||||
DELETE FROM events WHERE id = ?;
|
DELETE FROM events WHERE id = ?;
|
||||||
|
|
||||||
@@ -65,8 +68,8 @@ SELECT COUNT(*) FROM claims WHERE slot_id = ?;
|
|||||||
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
||||||
|
|
||||||
-- name: CreateRsvp :one
|
-- name: CreateRsvp :one
|
||||||
INSERT INTO rsvps (event_id, name, note, plus_one)
|
INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: DeleteRsvp :exec
|
-- name: DeleteRsvp :exec
|
||||||
@@ -78,6 +81,12 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
|
|||||||
-- name: GetRsvpByName :one
|
-- name: GetRsvpByName :one
|
||||||
SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1;
|
SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetRsvpByUser :one
|
||||||
|
SELECT * FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CountGoing :one
|
||||||
|
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ?;
|
||||||
|
|
||||||
-- name: DeleteDuplicateRsvps :exec
|
-- name: DeleteDuplicateRsvps :exec
|
||||||
DELETE FROM rsvps WHERE id NOT IN (
|
DELETE FROM rsvps WHERE id NOT IN (
|
||||||
SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE
|
SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE
|
||||||
|
|||||||
+74
-12
@@ -22,6 +22,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countGoing = `-- name: CountGoing :one
|
||||||
|
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountGoing(ctx context.Context, eventID int64) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countGoing, eventID)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const countRsvps = `-- name: CountRsvps :one
|
const countRsvps = `-- name: CountRsvps :one
|
||||||
SELECT COUNT(*) FROM rsvps WHERE event_id = ?
|
SELECT COUNT(*) FROM rsvps WHERE event_id = ?
|
||||||
`
|
`
|
||||||
@@ -59,9 +70,9 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createEvent = `-- name: CreateEvent :one
|
const createEvent = `-- name: CreateEvent :one
|
||||||
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id, slug, title, date, time, location, admin_token, description, user_id, created_at
|
RETURNING id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateEventParams struct {
|
type CreateEventParams struct {
|
||||||
@@ -72,6 +83,7 @@ type CreateEventParams struct {
|
|||||||
Location string
|
Location string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
Description string
|
Description string
|
||||||
|
AttendeeCap int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) {
|
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) {
|
||||||
@@ -83,6 +95,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
arg.Location,
|
arg.Location,
|
||||||
arg.AdminToken,
|
arg.AdminToken,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.AttendeeCap,
|
||||||
)
|
)
|
||||||
var i Event
|
var i Event
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -94,6 +107,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.AttendeeCap,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -101,9 +115,9 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createRsvp = `-- name: CreateRsvp :one
|
const createRsvp = `-- name: CreateRsvp :one
|
||||||
INSERT INTO rsvps (event_id, name, note, plus_one)
|
INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
RETURNING id, event_id, name, note, plus_one, created_at
|
RETURNING id, event_id, name, note, plus_one, user_id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateRsvpParams struct {
|
type CreateRsvpParams struct {
|
||||||
@@ -111,6 +125,7 @@ type CreateRsvpParams struct {
|
|||||||
Name string
|
Name string
|
||||||
Note string
|
Note string
|
||||||
PlusOne int64
|
PlusOne int64
|
||||||
|
UserID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
|
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
|
||||||
@@ -119,6 +134,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
|
|||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Note,
|
arg.Note,
|
||||||
arg.PlusOne,
|
arg.PlusOne,
|
||||||
|
arg.UserID,
|
||||||
)
|
)
|
||||||
var i Rsvp
|
var i Rsvp
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -127,6 +143,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -328,7 +345,7 @@ func (q *Queries) GetClaimByID(ctx context.Context, id int64) (Claim, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getEventByAdminToken = `-- name: GetEventByAdminToken :one
|
const getEventByAdminToken = `-- name: GetEventByAdminToken :one
|
||||||
SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE admin_token = ?
|
SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE admin_token = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) {
|
func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) {
|
||||||
@@ -343,6 +360,7 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (
|
|||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.AttendeeCap,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -350,7 +368,7 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getEventBySlug = `-- name: GetEventBySlug :one
|
const getEventBySlug = `-- name: GetEventBySlug :one
|
||||||
SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE slug = ?
|
SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE slug = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) {
|
func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) {
|
||||||
@@ -365,6 +383,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
|
|||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.AttendeeCap,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -372,7 +391,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRsvp = `-- name: GetRsvp :one
|
const getRsvp = `-- name: GetRsvp :one
|
||||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE id = ?
|
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
||||||
@@ -384,13 +403,14 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRsvpByName = `-- name: GetRsvpByName :one
|
const getRsvpByName = `-- name: GetRsvpByName :one
|
||||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetRsvpByNameParams struct {
|
type GetRsvpByNameParams struct {
|
||||||
@@ -407,6 +427,31 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRsvpByUser = `-- name: GetRsvpByUser :one
|
||||||
|
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetRsvpByUserParams struct {
|
||||||
|
EventID int64
|
||||||
|
UserID sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetRsvpByUser(ctx context.Context, arg GetRsvpByUserParams) (Rsvp, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getRsvpByUser, arg.EventID, arg.UserID)
|
||||||
|
var i Rsvp
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.EventID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Note,
|
||||||
|
&i.PlusOne,
|
||||||
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -569,7 +614,7 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listEventsByUser = `-- name: ListEventsByUser :many
|
const listEventsByUser = `-- name: ListEventsByUser :many
|
||||||
SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC
|
SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([]Event, error) {
|
func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([]Event, error) {
|
||||||
@@ -590,6 +635,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
|
|||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
|
&i.AttendeeCap,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -607,7 +653,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRsvps = `-- name: ListRsvps :many
|
const listRsvps = `-- name: ListRsvps :many
|
||||||
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
||||||
@@ -625,6 +671,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error)
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -720,6 +767,21 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateEventDateTime = `-- name: UpdateEventDateTime :exec
|
||||||
|
UPDATE events SET date = ?, time = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateEventDateTimeParams struct {
|
||||||
|
Date string
|
||||||
|
Time string
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateEventDateTime(ctx context.Context, arg UpdateEventDateTimeParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateEventDateTime, arg.Date, arg.Time, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateEventDescription = `-- name: UpdateEventDescription :exec
|
const updateEventDescription = `-- name: UpdateEventDescription :exec
|
||||||
UPDATE events SET description = ? WHERE id = ?
|
UPDATE events SET description = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|||||||
+158
-19
@@ -24,8 +24,18 @@ const (
|
|||||||
maxRsvps = 200
|
maxRsvps = 200
|
||||||
maxClaims = 50
|
maxClaims = 50
|
||||||
maxMaxClaims = 50
|
maxMaxClaims = 50
|
||||||
|
|
||||||
|
maxAttendeeCap = 1000
|
||||||
|
maxPlusOne = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func plural(n int64) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
|
||||||
func sanitize(s string, maxLen int) string {
|
func sanitize(s string, maxLen int) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) > maxLen {
|
if len(s) > maxLen {
|
||||||
@@ -49,6 +59,10 @@ func randomSlug() string {
|
|||||||
// --- Home / Create Event ---
|
// --- Home / Create Event ---
|
||||||
|
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.features.Auth && s.currentUser(r) == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
pageTmpl["home"].ExecuteTemplate(w, "layout", map[string]any{
|
pageTmpl["home"].ExecuteTemplate(w, "layout", map[string]any{
|
||||||
"User": s.currentUser(r),
|
"User": s.currentUser(r),
|
||||||
"AuthEnabled": s.features.Auth,
|
"AuthEnabled": s.features.Auth,
|
||||||
@@ -69,10 +83,22 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capped events require login to RSVP, so the cap is only available when auth is on.
|
||||||
|
var attendeeCap int64
|
||||||
|
if s.features.Auth {
|
||||||
|
if v, err := strconv.ParseInt(r.FormValue("attendee_cap"), 10, 64); err == nil && v > 0 {
|
||||||
|
attendeeCap = v
|
||||||
|
}
|
||||||
|
if attendeeCap > maxAttendeeCap {
|
||||||
|
attendeeCap = maxAttendeeCap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slug := randomSlug()
|
slug := randomSlug()
|
||||||
token := randomToken()
|
token := randomToken()
|
||||||
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{
|
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{
|
||||||
Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description,
|
Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description,
|
||||||
|
AttendeeCap: attendeeCap,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("create event: %v", err)
|
log.Printf("create event: %v", err)
|
||||||
@@ -150,6 +176,10 @@ type EventPageData struct {
|
|||||||
DescriptionHTML template.HTML
|
DescriptionHTML template.HTML
|
||||||
User *db.User
|
User *db.User
|
||||||
AuthEnabled bool
|
AuthEnabled bool
|
||||||
|
Capped bool
|
||||||
|
SpotsLeft int64
|
||||||
|
CapFull bool
|
||||||
|
PlusOneMax int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
||||||
@@ -213,6 +243,22 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
descHTML = RenderMarkdown(event.Description)
|
descHTML = RenderMarkdown(event.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capped := s.features.Auth && event.AttendeeCap > 0
|
||||||
|
var spotsLeft int64
|
||||||
|
plusOneMax := int64(maxPlusOne)
|
||||||
|
if capped {
|
||||||
|
spotsLeft = event.AttendeeCap - totalGoing
|
||||||
|
if spotsLeft < 0 {
|
||||||
|
spotsLeft = 0
|
||||||
|
}
|
||||||
|
if spotsLeft-1 < plusOneMax {
|
||||||
|
plusOneMax = spotsLeft - 1
|
||||||
|
}
|
||||||
|
if plusOneMax < 0 {
|
||||||
|
plusOneMax = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &EventPageData{
|
return &EventPageData{
|
||||||
Event: event,
|
Event: event,
|
||||||
Slots: slotViews,
|
Slots: slotViews,
|
||||||
@@ -224,6 +270,10 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
DescriptionHTML: descHTML,
|
DescriptionHTML: descHTML,
|
||||||
User: s.currentUser(r),
|
User: s.currentUser(r),
|
||||||
AuthEnabled: s.features.Auth,
|
AuthEnabled: s.features.Auth,
|
||||||
|
Capped: capped,
|
||||||
|
SpotsLeft: spotsLeft,
|
||||||
|
CapFull: capped && totalGoing >= event.AttendeeCap,
|
||||||
|
PlusOneMax: plusOneMax,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +370,38 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capped events require login and enforce the cap on total people going.
|
||||||
|
capped := s.features.Auth && event.AttendeeCap > 0
|
||||||
|
user := s.currentUser(r)
|
||||||
|
alreadyRsvped := false
|
||||||
|
if capped {
|
||||||
|
if user == nil {
|
||||||
|
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
|
||||||
|
EventID: event.ID,
|
||||||
|
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
|
})
|
||||||
|
alreadyRsvped = err == nil
|
||||||
|
if !alreadyRsvped {
|
||||||
|
going, err := s.q.CountGoing(r.Context(), event.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if going+1+plusOne > event.AttendeeCap {
|
||||||
|
left := event.AttendeeCap - going
|
||||||
|
if left <= 0 {
|
||||||
|
http.Error(w, "Event is full", http.StatusConflict)
|
||||||
|
} else {
|
||||||
|
http.Error(w, fmt.Sprintf("Only %d spot%s left", left, plural(left)), http.StatusConflict)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Optional slot claim
|
// Optional slot claim
|
||||||
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
||||||
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
||||||
@@ -359,27 +441,33 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create RSVP (deduped — skip if already on the list)
|
// Create RSVP (deduped — skip if already on the list)
|
||||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
if !alreadyRsvped {
|
||||||
EventID: event.ID, Name: name,
|
var userID sql.NullInt64
|
||||||
})
|
if user != nil {
|
||||||
if err == sql.ErrNoRows {
|
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
||||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if count >= maxRsvps {
|
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||||
http.Error(w, "RSVP list is full", http.StatusConflict)
|
EventID: event.ID, Name: name,
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
|
||||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
log.Printf("create rsvp: %v", err)
|
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||||
http.Error(w, "Failed", http.StatusInternalServerError)
|
if err != nil {
|
||||||
return
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= maxRsvps {
|
||||||
|
http.Error(w, "RSVP list is full", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||||
|
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("create rsvp: %v", err)
|
||||||
|
http.Error(w, "Failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +526,29 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
plusOne = 10
|
plusOne = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Event not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Raising plus_one can't exceed the attendee cap.
|
||||||
|
if s.features.Auth && event.AttendeeCap > 0 {
|
||||||
|
old, err := s.q.GetRsvp(r.Context(), rsvpID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "RSVP not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
going, err := s.q.CountGoing(r.Context(), event.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if going-old.PlusOne+plusOne > event.AttendeeCap {
|
||||||
|
http.Error(w, "Not enough spots left", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
||||||
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
|
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
|
||||||
})
|
})
|
||||||
@@ -596,6 +707,26 @@ func (s *Server) handleUpdateLocation(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateDateTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
event := s.authorizeAdmin(w, r, false)
|
||||||
|
if event == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||||
|
r.ParseForm()
|
||||||
|
date := sanitize(r.FormValue("date"), maxFieldLen)
|
||||||
|
time_ := sanitize(r.FormValue("time"), maxFieldLen)
|
||||||
|
|
||||||
|
s.q.UpdateEventDateTime(r.Context(), db.UpdateEventDateTimeParams{
|
||||||
|
Date: date,
|
||||||
|
Time: time_,
|
||||||
|
ID: event.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
|
||||||
event := s.authorizeAdmin(w, r, false)
|
event := s.authorizeAdmin(w, r, false)
|
||||||
if event == nil {
|
if event == nil {
|
||||||
@@ -665,3 +796,11 @@ func (s *Server) handleDeleteSlot(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePrivacy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageTmpl["privacy"].ExecuteTemplate(w, "layout", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTerms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageTmpl["terms"].ExecuteTemplate(w, "layout", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,6 +215,115 @@ func TestUpdateRsvp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateEventDateTime(t *testing.T) {
|
||||||
|
_, q := setupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
|
err := q.UpdateEventDateTime(ctx, db.UpdateEventDateTimeParams{
|
||||||
|
Date: "Sunday, July 20", Time: "5:30 PM", ID: event.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := q.GetEventBySlug(ctx, event.Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.Date != "Sunday, July 20" {
|
||||||
|
t.Fatalf("expected date 'Sunday, July 20', got %s", updated.Date)
|
||||||
|
}
|
||||||
|
if updated.Time != "5:30 PM" {
|
||||||
|
t.Fatalf("expected time '5:30 PM', got %s", updated.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountGoing(t *testing.T) {
|
||||||
|
_, q := setupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
|
going, err := q.CountGoing(ctx, event.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if going != 0 {
|
||||||
|
t.Fatalf("expected 0 going on empty event, got %d", going)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0})
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2})
|
||||||
|
|
||||||
|
going, err = q.CountGoing(ctx, event.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if going != 4 {
|
||||||
|
t.Fatalf("expected 4 going (2 RSVPs + 2 plus-ones), got %d", going)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRsvpByUser(t *testing.T) {
|
||||||
|
_, q := setupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
|
user, err := q.CreateUserByEmail(ctx, sql.NullString{String: "alice@example.com", Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{
|
||||||
|
EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
t.Fatalf("expected ErrNoRows before RSVP, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||||
|
EventID: event.ID, Name: "Alice", PlusOne: 1,
|
||||||
|
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
rsvp, err := q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{
|
||||||
|
EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rsvp.Name != "Alice" {
|
||||||
|
t.Fatalf("expected RSVP name Alice, got %s", rsvp.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttendeeCapArithmetic(t *testing.T) {
|
||||||
|
_, q := setupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
event, err := q.CreateEvent(ctx, db.CreateEventParams{
|
||||||
|
Slug: "capped", Title: "Capped Event", Date: "June 1", Time: "2pm",
|
||||||
|
Location: "Park", AdminToken: "tok456", AttendeeCap: 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event.AttendeeCap != 3 {
|
||||||
|
t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1})
|
||||||
|
|
||||||
|
going, _ := q.CountGoing(ctx, event.ID)
|
||||||
|
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap.
|
||||||
|
if going+1+1 <= event.AttendeeCap {
|
||||||
|
t.Fatalf("expected RSVP +1 to exceed cap (going=%d, cap=%d)", going, event.AttendeeCap)
|
||||||
|
}
|
||||||
|
// A solo RSVP still fits.
|
||||||
|
if going+1+0 > event.AttendeeCap {
|
||||||
|
t.Fatalf("expected solo RSVP to fit (going=%d, cap=%d)", going, event.AttendeeCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRsvpPlusOne(t *testing.T) {
|
func TestRsvpPlusOne(t *testing.T) {
|
||||||
_, q := setupTestDB(t)
|
_, q := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func main() {
|
|||||||
// Parse each page with layout + shared partials
|
// Parse each page with layout + shared partials
|
||||||
pageTmpl = make(map[string]*template.Template)
|
pageTmpl = make(map[string]*template.Template)
|
||||||
shared := []string{"templates/layout.html", "templates/slots.html"}
|
shared := []string{"templates/layout.html", "templates/slots.html"}
|
||||||
for _, page := range []string{"home", "event", "login", "dashboard", "name"} {
|
for _, page := range []string{"home", "event", "login", "dashboard", "name", "privacy", "terms"} {
|
||||||
files := append([]string{"templates/" + page + ".html"}, shared...)
|
files := append([]string{"templates/" + page + ".html"}, shared...)
|
||||||
pageTmpl[page] = template.Must(
|
pageTmpl[page] = template.Must(
|
||||||
template.New("").Funcs(funcMap).ParseFS(templateFS, files...),
|
template.New("").Funcs(funcMap).ParseFS(templateFS, files...),
|
||||||
@@ -172,9 +172,17 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legal
|
||||||
|
r.Get("/privacy", srv.handlePrivacy)
|
||||||
|
r.Get("/terms", srv.handleTerms)
|
||||||
|
|
||||||
// Home / create event
|
// Home / create event
|
||||||
r.Get("/", srv.handleHome)
|
r.Get("/", srv.handleHome)
|
||||||
r.Post("/events", srv.handleCreateEvent)
|
if features.Auth {
|
||||||
|
r.With(srv.requireAuth).Post("/events", srv.handleCreateEvent)
|
||||||
|
} else {
|
||||||
|
r.Post("/events", srv.handleCreateEvent)
|
||||||
|
}
|
||||||
|
|
||||||
// Guest event view
|
// Guest event view
|
||||||
r.Get("/e/{slug}", srv.handleEvent)
|
r.Get("/e/{slug}", srv.handleEvent)
|
||||||
@@ -195,6 +203,7 @@ func main() {
|
|||||||
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
|
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
|
||||||
r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription)
|
r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription)
|
||||||
r.Post("/e/{slug}/admin/{token}/location", srv.handleUpdateLocation)
|
r.Post("/e/{slug}/admin/{token}/location", srv.handleUpdateLocation)
|
||||||
|
r.Post("/e/{slug}/admin/{token}/datetime", srv.handleUpdateDateTime)
|
||||||
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
|
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
|
||||||
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
|
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
|
||||||
|
|
||||||
|
|||||||
+61
-3
@@ -10,9 +10,6 @@ func runMigrations(database *sql.DB) {
|
|||||||
migrations := []string{
|
migrations := []string{
|
||||||
`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`,
|
`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`,
|
||||||
`ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`,
|
`ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`,
|
||||||
// Users may have email, phone, or both. Add whichever column is missing.
|
|
||||||
`ALTER TABLE users ADD COLUMN phone TEXT UNIQUE`,
|
|
||||||
`ALTER TABLE users ADD COLUMN email TEXT UNIQUE`,
|
|
||||||
// Verification codes use a generic identifier column.
|
// Verification codes use a generic identifier column.
|
||||||
`ALTER TABLE verification_codes ADD COLUMN identifier TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE verification_codes ADD COLUMN identifier TEXT NOT NULL DEFAULT ''`,
|
||||||
// Indexes for auth tables (created here so they run after column migrations).
|
// Indexes for auth tables (created here so they run after column migrations).
|
||||||
@@ -22,6 +19,12 @@ func runMigrations(database *sql.DB) {
|
|||||||
`DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`,
|
`DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`,
|
||||||
// Plus-one tracking for RSVPs.
|
// Plus-one tracking for RSVPs.
|
||||||
`ALTER TABLE rsvps ADD COLUMN plus_one INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE rsvps ADD COLUMN plus_one INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Drop legacy phone/email columns from verification_codes. They were
|
||||||
|
// NOT NULL with no default, so inserts using only identifier failed.
|
||||||
|
`DROP INDEX IF EXISTS idx_verification_codes_phone`,
|
||||||
|
`DROP INDEX IF EXISTS idx_verification_codes_email`,
|
||||||
|
`ALTER TABLE verification_codes DROP COLUMN phone`,
|
||||||
|
`ALTER TABLE verification_codes DROP COLUMN email`,
|
||||||
// SMS consent logging for Twilio compliance.
|
// SMS consent logging for Twilio compliance.
|
||||||
`CREATE TABLE IF NOT EXISTS sms_consents (
|
`CREATE TABLE IF NOT EXISTS sms_consents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -30,6 +33,9 @@ func runMigrations(database *sql.DB) {
|
|||||||
user_agent TEXT NOT NULL DEFAULT '',
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
// Attendee cap (0 = uncapped); capped events require login to RSVP.
|
||||||
|
`ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`,
|
||||||
}
|
}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
_, err := database.Exec(m)
|
_, err := database.Exec(m)
|
||||||
@@ -41,4 +47,56 @@ func runMigrations(database *sql.DB) {
|
|||||||
log.Printf("migration warning: %v", err)
|
log.Printf("migration warning: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rebuildLegacyUsersTable(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildLegacyUsersTable migrates users tables from the phone-auth era
|
||||||
|
// (phone NOT NULL, no email column). SQLite can't add a UNIQUE column or
|
||||||
|
// drop NOT NULL via ALTER TABLE, so the table has to be rebuilt. Detected
|
||||||
|
// by the missing email column.
|
||||||
|
func rebuildLegacyUsersTable(database *sql.DB) {
|
||||||
|
var hasEmail int
|
||||||
|
err := database.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'email'`,
|
||||||
|
).Scan(&hasEmail)
|
||||||
|
if err != nil || hasEmail > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable FK enforcement so DROP TABLE users doesn't cascade-delete
|
||||||
|
// sessions. main.go sets MaxOpenConns(1), so these pragmas apply to
|
||||||
|
// the same connection the transaction runs on.
|
||||||
|
database.Exec(`PRAGMA foreign_keys=OFF`)
|
||||||
|
defer database.Exec(`PRAGMA foreign_keys=ON`)
|
||||||
|
|
||||||
|
tx, err := database.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("users rebuild: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
phone TEXT UNIQUE,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`INSERT INTO users_new (id, phone, name, created_at)
|
||||||
|
SELECT id, phone, name, created_at FROM users`,
|
||||||
|
`DROP TABLE users`,
|
||||||
|
`ALTER TABLE users_new RENAME TO users`,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
|
log.Printf("users rebuild: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Printf("users rebuild: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
admin_token TEXT NOT NULL UNIQUE,
|
admin_token TEXT NOT NULL UNIQUE,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
attendee_cap INTEGER NOT NULL DEFAULT 0, -- 0 = uncapped; capped events require login to RSVP
|
||||||
user_id INTEGER REFERENCES users(id),
|
user_id INTEGER REFERENCES users(id),
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -34,6 +35,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
plus_one INTEGER NOT NULL DEFAULT 0,
|
plus_one INTEGER NOT NULL DEFAULT 0,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
<div class="event-tag">Open · {{.TotalGoing}} going</div>
|
<div class="event-tag">Open · {{.TotalGoing}} going{{if .Capped}} · {{if .CapFull}}FULL{{else}}{{.SpotsLeft}} spot{{if ne .SpotsLeft 1}}s{{end}} left{{end}}{{end}}</div>
|
||||||
<h1 class="event-title">{{.Event.Title}}</h1>
|
<h1 class="event-title">{{.Event.Title}}</h1>
|
||||||
<div class="event-meta">
|
<div class="event-meta">
|
||||||
{{if .Event.Date}}<span>📅 {{.Event.Date}}</span>{{end}}
|
{{if .Event.Date}}<span>📅 {{.Event.Date}}</span>{{end}}
|
||||||
@@ -41,6 +41,23 @@
|
|||||||
|
|
||||||
<div class="section-label" style="margin-top:40px">Sign up</div>
|
<div class="section-label" style="margin-top:40px">Sign up</div>
|
||||||
|
|
||||||
|
{{if and .Capped .CapFull}}
|
||||||
|
<div class="claim-form-wrapper">
|
||||||
|
<div class="form-title">Event is full</div>
|
||||||
|
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;">
|
||||||
|
All {{.Event.AttendeeCap}} spots are taken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{else if and .Capped (not .User)}}
|
||||||
|
<div class="claim-form-wrapper">
|
||||||
|
<div class="form-title">Log in to RSVP</div>
|
||||||
|
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
|
||||||
|
This event has limited spots ({{.SpotsLeft}} left), so RSVPs require an account.
|
||||||
|
</p>
|
||||||
|
<a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;"
|
||||||
|
href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP ↗</a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<div class="form-title">I'm coming →</div>
|
<div class="form-title">I'm coming →</div>
|
||||||
<form hx-post="/e/{{.Event.Slug}}/rsvp"
|
<form hx-post="/e/{{.Event.Slug}}/rsvp"
|
||||||
@@ -64,7 +81,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Bringing anyone?</label>
|
<label>Bringing anyone?</label>
|
||||||
<input type="number" name="plus_one" value="0" min="0" max="10">
|
<input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" id="claim-note">
|
<div class="form-row" id="claim-note">
|
||||||
<label>Note (optional)</label>
|
<label>Note (optional)</label>
|
||||||
@@ -73,8 +90,24 @@
|
|||||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
<button class="btn-submit" type="submit">Count me in ↗</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
|
<div class="section-label" style="margin-top:40px">Admin: Date & time</div>
|
||||||
|
<div class="claim-form-wrapper">
|
||||||
|
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/datetime">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="text" name="date" value="{{.Event.Date}}" placeholder="e.g. Saturday, June 14">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Time</label>
|
||||||
|
<input type="text" name="time" value="{{.Event.Time}}" placeholder="e.g. 2:00 PM">
|
||||||
|
</div>
|
||||||
|
<button class="btn-submit" type="submit">Save date & time</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-label" style="margin-top:40px">Admin: Location</div>
|
<div class="section-label" style="margin-top:40px">Admin: Location</div>
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/location">
|
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/location">
|
||||||
|
|||||||
@@ -33,6 +33,12 @@
|
|||||||
<label>Description (optional, markdown supported)</label>
|
<label>Description (optional, markdown supported)</label>
|
||||||
<textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea>
|
<textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .AuthEnabled}}
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Attendee cap (optional — guests must log in to RSVP)</label>
|
||||||
|
<input type="number" name="attendee_cap" min="0" max="1000" placeholder="e.g. 30 — leave blank for unlimited">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="section-label" style="margin:24px 0 16px">Slots</div>
|
<div class="section-label" style="margin:24px 0 16px">Slots</div>
|
||||||
|
|
||||||
|
|||||||
@@ -477,5 +477,10 @@ document.body.addEventListener('closeModal', function() {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<div id="edit-modal"></div>
|
<div id="edit-modal"></div>
|
||||||
|
<footer style="border-top:2.5px solid var(--border);padding:16px 24px;text-align:center;font-size:0.8rem;color:#777;margin-top:auto;">
|
||||||
|
<a href="/privacy" style="color:#777;text-decoration:none;margin:0 8px;">Privacy</a>
|
||||||
|
<span style="color:#bbb;">·</span>
|
||||||
|
<a href="/terms" style="color:#777;text-decoration:none;margin:0 8px;">Terms</a>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
{{if eq .Step "identify"}}
|
{{if eq .Step "identify"}}
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
|
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" name="identifier" placeholder="you@example.com" required autofocus>
|
<input type="email" name="identifier" placeholder="you@example.com" required autofocus>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<form method="POST" action="/login/verify">
|
<form method="POST" action="/login/verify">
|
||||||
<input type="hidden" name="identifier" value="{{.Identifier}}">
|
<input type="hidden" name="identifier" value="{{.Identifier}}">
|
||||||
|
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||||
<p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;">
|
<p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;">
|
||||||
Code sent to <strong>{{.Identifier}}</strong>
|
Code sent to <strong>{{.Identifier}}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<form method="POST" action="/account/name">
|
<form method="POST" action="/account/name">
|
||||||
|
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" name="name" placeholder="Your name" required autofocus
|
<input type="text" name="name" placeholder="Your name" required autofocus
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "title"}}Privacy Policy — bbq{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="page-wrap" style="max-width:680px;margin:48px auto;padding:0 24px 64px;">
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:8px;">Privacy Policy</h1>
|
||||||
|
<p style="color:#555;font-size:0.9rem;margin-bottom:40px;">Last updated July 3, 2026</p>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">What we collect</h2>
|
||||||
|
<p style="line-height:1.7;margin-bottom:12px;">When you RSVP or claim a slot at an event, we store your <strong>name</strong> and an optional <strong>note</strong> you provide. These are visible to anyone with the event link and to the event organizer.</p>
|
||||||
|
<p style="line-height:1.7;margin-bottom:12px;">If you create an event with an attendee cap, you may be asked to sign in with your <strong>email address</strong>. We store your email and a short-lived verification code to confirm it. We do not store passwords.</p>
|
||||||
|
<p style="line-height:1.7;">We do not collect addresses, phone numbers (unless you opt into SMS notifications on a specific event), or payment information.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">How we use it</h2>
|
||||||
|
<p style="line-height:1.7;">Your data is used only to run the event signup — showing who has claimed what, letting you manage your own RSVP, and letting the organizer see their guest list. We do not sell, share, or use your data for advertising.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Third parties</h2>
|
||||||
|
<p style="line-height:1.7;">We load fonts from Google Fonts and the HTMX library from unpkg. These requests go to their servers and are subject to their privacy policies. We have no other third-party integrations and use no analytics or tracking tools.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Cookies & sessions</h2>
|
||||||
|
<p style="line-height:1.7;">If you sign in, a session token is stored in a cookie to keep you logged in. No other cookies are set.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Data retention</h2>
|
||||||
|
<p style="line-height:1.7;">Event and RSVP data persists indefinitely unless an organizer deletes slots or the event is removed. There is no automated purge schedule. If you want your data removed, contact us.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Contact</h2>
|
||||||
|
<p style="line-height:1.7;">Questions or deletion requests: <a href="mailto:ryan@torrtle.co" style="color:var(--ink);">ryan@torrtle.co</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-label">Going ({{.TotalGoing}})</div>
|
<div class="section-label">Going ({{.TotalGoing}}{{if .Capped}} / {{.Event.AttendeeCap}}{{end}})</div>
|
||||||
<div class="rsvp-list">
|
<div class="rsvp-list">
|
||||||
{{if .GoingList}}
|
{{if .GoingList}}
|
||||||
{{range .GoingList}}
|
{{range .GoingList}}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "title"}}Terms of Service — bbq{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="page-wrap" style="max-width:680px;margin:48px auto;padding:0 24px 64px;">
|
||||||
|
<h1 style="font-size:2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:8px;">Terms of Service</h1>
|
||||||
|
<p style="color:#555;font-size:0.9rem;margin-bottom:40px;">Last updated July 3, 2026</p>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">What this is</h2>
|
||||||
|
<p style="line-height:1.7;">bbq is a simple potluck signup tool. You can create an event, share a link, and let people claim slots. That's it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Your responsibilities</h2>
|
||||||
|
<p style="line-height:1.7;margin-bottom:12px;">By using this service you agree not to use it for spam, harassment, illegal activity, or anything that would embarrass a reasonable person at a potluck.</p>
|
||||||
|
<p style="line-height:1.7;">Event organizers are responsible for the events they create and the information they share with guests. Guests are responsible for the names and notes they submit.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">No warranty</h2>
|
||||||
|
<p style="line-height:1.7;">This service is provided as-is, without any warranty. We make no guarantees about uptime, data durability, or fitness for any particular purpose. Use it at your own risk.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-bottom:32px;">
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Changes</h2>
|
||||||
|
<p style="line-height:1.7;">We may update these terms at any time. Continued use of the service after changes constitutes acceptance of the new terms.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Contact</h2>
|
||||||
|
<p style="line-height:1.7;"><a href="mailto:ryan@torrtle.co" style="color:var(--ink);">ryan@torrtle.co</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user