package todo import ( "log/slog" "net/http" "strconv" "github.com/go-chi/chi/v5" "gorm.io/gorm" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/form" "amadan.net/rastrillo/rastrillo/scope" "amadan.net/rastrillo/rastrillo/sessions" ) // notFoundTitle is the 404 page's heading: an item that isn't in your // list. (make bump edits this line.) const notFoundTitle = "That item is not in your list" // app holds what every handler needs. type app struct { db *gorm.DB sess *sessions.Sessions logger *slog.Logger } // owned is the one seam every todo query goes through: reads and // writes alike carry WHERE user_id = . func (a *app) owned(r *http.Request) *gorm.DB { uid, _ := sessions.UserID(r) return scope.Owned(a.db.WithContext(r.Context()), uid) } // page serves one filter's view of the list, with ?edit= opening // that item's editor. func (a *app) page(f filter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var todos []Todo if err := a.owned(r).Order("id").Find(&todos).Error; err != nil { a.fail(w, r, err) return } editing, _ := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64) w.Header().Set("Cache-Control", "no-store") render(w, "index", newView(todos, f, editing)) } } func (a *app) create(w http.ResponseWriter, r *http.Request) { p := form.Parse(r, form.Field{Name: "title"}) if title := p.String("title"); title != "" { uid, _ := sessions.UserID(r) if err := a.db.WithContext(r.Context()).Create(&Todo{UserID: uid, Title: title}).Error; err != nil { a.fail(w, r, err) return } } a.done(w, r) } func (a *app) toggle(w http.ResponseWriter, r *http.Request) { id, ok := itemID(r) if !ok { a.notFound(w, r) return } res := a.owned(r).Model(&Todo{}).Where("id = ?", id). Update("completed", gorm.Expr("NOT completed")) a.afterItemWrite(w, r, res) } func (a *app) edit(w http.ResponseWriter, r *http.Request) { id, ok := itemID(r) if !ok { a.notFound(w, r) return } p := form.Parse(r, form.Field{Name: "title"}) title := p.String("title") var res *gorm.DB if title == "" { res = a.owned(r).Where("id = ?", id).Delete(&Todo{}) } else { res = a.owned(r).Model(&Todo{}).Where("id = ?", id).Update("title", title) } a.afterItemWrite(w, r, res) } func (a *app) destroy(w http.ResponseWriter, r *http.Request) { id, ok := itemID(r) if !ok { a.notFound(w, r) return } a.afterItemWrite(w, r, a.owned(r).Where("id = ?", id).Delete(&Todo{})) } // toggleAll completes every item, or reopens them all when every one // already was: one statement, so it can't race a concurrent toggle. func (a *app) toggleAll(w http.ResponseWriter, r *http.Request) { uid, _ := sessions.UserID(r) err := a.owned(r).Model(&Todo{}).Where("1 = 1").Update("completed", gorm.Expr("EXISTS (SELECT 1 FROM todos WHERE user_id = ? AND NOT completed)", uid)).Error if err != nil { a.fail(w, r, err) return } a.done(w, r) } func (a *app) clearCompleted(w http.ResponseWriter, r *http.Request) { if err := a.owned(r).Where("completed").Delete(&Todo{}).Error; err != nil { a.fail(w, r, err) return } a.done(w, r) } // afterItemWrite finishes a write to one item: an error is a 500, no // row touched means the id isn't in this visitor's list (404). func (a *app) afterItemWrite(w http.ResponseWriter, r *http.Request, res *gorm.DB) { switch { case res.Error != nil: a.fail(w, r, res.Error) case res.RowsAffected == 0: a.notFound(w, r) default: a.done(w, r) } } // done answers a successful action: 303 back to the filter the form // came from, or, for the page's own script, the list's new markup. func (a *app) done(w http.ResponseWriter, r *http.Request) { f := parseFilter(r.PostFormValue("filter")) if !wantsFragment(r) { http.Redirect(w, r, f.path(), http.StatusSeeOther) return } var todos []Todo if err := a.owned(r).Order("id").Find(&todos).Error; err != nil { a.fail(w, r, err) return } w.Header().Set("Cache-Control", "no-store") render(w, "app", newView(todos, f, 0)) } func (a *app) notFound(w http.ResponseWriter, r *http.Request) { errorPage(w, http.StatusNotFound, notFoundTitle) } // fail logs err under a fresh ref and renders the 500 page carrying it. func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) { ref := rastrillo.NewRef() a.logger.Error("request failed", "ref", ref, "method", r.Method, "path", r.URL.Path, "err", err) ErrorPage(w, r, http.StatusInternalServerError, ref) } // itemID reads {id}. A malformed id is a row that doesn't exist. func itemID(r *http.Request) (int64, bool) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) return id, err == nil && id > 0 } // fragmentHeader is what the page's script sends to get the list's // markup back instead of a 303. Without it nothing changes. const fragmentHeader = "X-Todo-Fragment" func wantsFragment(r *http.Request) bool { return r.Header.Get(fragmentHeader) != "" }