package api import ( "encoding/json" "errors" "net/http" "regexp" "strconv" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) type eventHandler struct { repo *storage.EventRepo collabs *storage.CollaboratorRepo enforcer *tierEnforcer } type createEventRequest struct { Name string `json:"name"` Slug string `json:"slug"` EventDate time.Time `json:"event_date"` Venue string `json:"venue"` MaxCapacity int `json:"max_capacity"` Settings map[string]any `json:"settings"` Status string `json:"status"` } var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } if !h.enforcer.allowEventCreate(w, r, hostID) { return } var req createEventRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if !slugRe.MatchString(req.Slug) { writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens") return } if req.EventDate.IsZero() { writeError(w, http.StatusBadRequest, "event_date is required") return } status := domain.EventStatus(req.Status) if status == "" { status = domain.EventStatusDraft } if !status.Valid() { writeError(w, http.StatusBadRequest, "invalid status") return } ev, err := h.repo.Create(r.Context(), storage.CreateEventParams{ HostID: hostID, Name: req.Name, Slug: req.Slug, EventDate: req.EventDate, Venue: req.Venue, MaxCapacity: req.MaxCapacity, Settings: req.Settings, Status: status, }) if err != nil { if errors.Is(err, domain.ErrSlugTaken) { writeError(w, http.StatusConflict, "slug already in use") return } writeError(w, http.StatusInternalServerError, "failed to create event") return } writeJSON(w, http.StatusCreated, ev) } // eventView wraps an Event with the caller's role so the dashboard UI can // branch (e.g. hide the "Delete event" button for editors/viewers). type eventView struct { *domain.Event YourRole domain.Role `json:"your_role"` } func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } id, ok := parseIDParam(w, r, "id") if !ok { return } ev, role, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleViewer) if !ok { return } writeJSON(w, http.StatusOK, eventView{Event: ev, YourRole: role}) } func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } q := r.URL.Query() limit := atoiOr(q.Get("limit"), 50) offset := atoiOr(q.Get("offset"), 0) // Block C: the dashboard shows every event the user has any role on, // not just events they own. The collaborators repo gives us the id set; // the events repo paginates the merged list. collabIDs, err := h.collabs.ListEventIDsForUser(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to resolve memberships") return } events, err := h.repo.ListForUser(r.Context(), hostID, collabIDs, limit, offset) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list events") return } if events == nil { events = []*domain.Event{} } writeJSON(w, http.StatusOK, map[string]any{ "events": events, "limit": limit, "offset": offset, }) } type updateEventRequest struct { Name *string `json:"name"` Slug *string `json:"slug"` EventDate *time.Time `json:"event_date"` Venue *string `json:"venue"` MaxCapacity *int `json:"max_capacity"` Settings *map[string]any `json:"settings"` Status *string `json:"status"` } func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } id, ok := parseIDParam(w, r, "id") if !ok { return } // Block C: editor+ can patch event metadata. The plan reserves DELETE // (and the existing host_id row) for owners only. if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleEditor); !ok { return } var req updateEventRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } params := storage.UpdateEventParams{ Name: req.Name, EventDate: req.EventDate, Venue: req.Venue, MaxCapacity: req.MaxCapacity, Settings: req.Settings, } if req.Slug != nil { if !slugRe.MatchString(*req.Slug) { writeError(w, http.StatusBadRequest, "slug must be lowercase alphanumeric with hyphens") return } params.Slug = req.Slug } if req.Status != nil { s := domain.EventStatus(*req.Status) if !s.Valid() { writeError(w, http.StatusBadRequest, "invalid status") return } params.Status = &s } ev, err := h.repo.UpdateByID(r.Context(), id, params) if err != nil { switch { case errors.Is(err, domain.ErrEventNotFound): writeError(w, http.StatusNotFound, "event not found") case errors.Is(err, domain.ErrSlugTaken): writeError(w, http.StatusConflict, "slug already in use") default: writeError(w, http.StatusInternalServerError, "failed to update event") } return } writeJSON(w, http.StatusOK, ev) } func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } id, ok := parseIDParam(w, r, "id") if !ok { return } // Block C: only owners can delete an event. Editors get 403; viewers // and non-members get 404. if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleOwner); !ok { return } if err := h.repo.DeleteByID(r.Context(), id); err != nil { if errors.Is(err, domain.ErrEventNotFound) { writeError(w, http.StatusNotFound, "event not found") return } writeError(w, http.StatusInternalServerError, "failed to delete event") return } w.WriteHeader(http.StatusNoContent) } func parseIDParam(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) { return parseRawUUID(w, name, r.PathValue(name)) } func parseRawUUID(w http.ResponseWriter, name, raw string) (uuid.UUID, bool) { if raw == "" { writeError(w, http.StatusBadRequest, name+" is required") return uuid.Nil, false } id, err := uuid.Parse(raw) if err != nil { writeError(w, http.StatusBadRequest, name+" must be a valid uuid") return uuid.Nil, false } return id, true } func atoiOr(s string, fallback int) int { if s == "" { return fallback } if n, err := strconv.Atoi(s); err == nil { return n } return fallback }