package main import ( "database/sql" "encoding/json" "net/http" "github.com/coder/websocket" ) type List struct { ID string `json:"id"` CreatedAt string `json:"created_at"` } type Item struct { ID int64 `json:"id"` Name string `json:"name"` Quantity int `json:"quantity"` Done bool `json:"done"` Position float64 `json:"position"` SublistID *int64 `json:"sublist_id"` CreatedAt string `json:"created_at"` } type Sublist struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` CreatedAt string `json:"created_at"` } type RecurringItem struct { ID int64 `json:"id"` Name string `json:"name"` SublistID *int64 `json:"sublist_id"` Position float64 `json:"position"` CreatedAt string `json:"created_at"` } type api struct { db querier hub *hub } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } func (a *api) listExists(id string) (bool, error) { var exists bool err := a.db.QueryRow("SELECT EXISTS(SELECT 1 FROM lists WHERE id = ?)", id).Scan(&exists) return exists, err } func (a *api) createList(w http.ResponseWriter, r *http.Request) { for attempt := 0; attempt < 5; attempt++ { id := generateSlug() if _, err := a.db.Exec("INSERT INTO lists (id) VALUES (?)", id); err == nil { writeJSON(w, http.StatusCreated, List{ID: id}) return } } writeError(w, http.StatusInternalServerError, "failed to create list") } func (a *api) getList(w http.ResponseWriter, r *http.Request) { id := r.PathValue("listId") var l List err := a.db.QueryRow("SELECT id, created_at FROM lists WHERE id = ?", id).Scan(&l.ID, &l.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "list not found") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, l) } // ensureGeneralSublist guarantees every item lands in a sublist: it makes // sure the list has at least one (creating a "General" catch-all if it has // none) and backfills any item/recurring row still missing a sublist_id // (left over from before sublists were required) onto it. func (a *api) ensureGeneralSublist(listID string) error { var orphanItems, orphanRecurring int if err := a.db.QueryRow("SELECT COUNT(*) FROM items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanItems); err != nil { return err } if err := a.db.QueryRow("SELECT COUNT(*) FROM recurring_items WHERE list_id = ? AND sublist_id IS NULL", listID).Scan(&orphanRecurring); err != nil { return err } var sublistCount int if err := a.db.QueryRow("SELECT COUNT(*) FROM sublists WHERE list_id = ?", listID).Scan(&sublistCount); err != nil { return err } if sublistCount > 0 && orphanItems == 0 && orphanRecurring == 0 { return nil } var generalID int64 err := a.db.QueryRow("SELECT id FROM sublists WHERE list_id = ? AND name = ? ORDER BY id LIMIT 1", listID, "General").Scan(&generalID) if err == sql.ErrNoRows { generalID, err = a.db.insertReturningID("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, "General", "#9ca3af") } if err != nil { return err } if orphanItems > 0 { if _, err := a.db.Exec("UPDATE items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil { return err } } if orphanRecurring > 0 { if _, err := a.db.Exec("UPDATE recurring_items SET sublist_id = ? WHERE list_id = ? AND sublist_id IS NULL", generalID, listID); err != nil { return err } } return nil } func (a *api) listSublists(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") if err := a.ensureGeneralSublist(listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } rows, err := a.db.Query("SELECT id, name, color, created_at FROM sublists WHERE list_id = ? ORDER BY id", listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() sublists := []Sublist{} for rows.Next() { var s Sublist if err := rows.Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } sublists = append(sublists, s) } writeJSON(w, http.StatusOK, sublists) } func (a *api) createSublist(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") exists, err := a.listExists(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !exists { writeError(w, http.StatusNotFound, "list not found") return } var in struct { Name string `json:"name"` Color string `json:"color"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if in.Color == "" { writeError(w, http.StatusBadRequest, "color is required") return } id, err := a.db.insertReturningID("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, in.Name, in.Color) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } var s Sublist err = a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ?", id). Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusCreated, s) } func (a *api) updateSublist(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") var in struct { Name *string `json:"name"` Color *string `json:"color"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Name != nil { if *in.Name == "" { writeError(w, http.StatusBadRequest, "name cannot be empty") return } if _, err := a.db.Exec("UPDATE sublists SET name = ? WHERE id = ? AND list_id = ?", *in.Name, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } if in.Color != nil { if *in.Color == "" { writeError(w, http.StatusBadRequest, "color cannot be empty") return } if _, err := a.db.Exec("UPDATE sublists SET color = ? WHERE id = ? AND list_id = ?", *in.Color, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } var s Sublist err := a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ? AND list_id = ?", id, listID). Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "sublist not found") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusOK, s) } func (a *api) deleteSublist(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") if _, err := a.db.Exec("DELETE FROM items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if _, err := a.db.Exec("DELETE FROM recurring_items WHERE sublist_id = ? AND list_id = ?", id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } res, err := a.db.Exec("DELETE FROM sublists WHERE id = ? AND list_id = ?", id, listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if n, _ := res.RowsAffected(); n == 0 { writeError(w, http.StatusNotFound, "sublist not found") return } a.hub.broadcast(listID) w.WriteHeader(http.StatusNoContent) } func (a *api) listRecurring(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") rows, err := a.db.Query("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE list_id = ? ORDER BY position, id", listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() recurring := []RecurringItem{} for rows.Next() { var ri RecurringItem if err := rows.Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } recurring = append(recurring, ri) } writeJSON(w, http.StatusOK, recurring) } func (a *api) createRecurring(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") exists, err := a.listExists(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !exists { writeError(w, http.StatusNotFound, "list not found") return } var in struct { Name string `json:"name"` SublistID *int64 `json:"sublist_id"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } var maxPosition sql.NullFloat64 if err := a.db.QueryRow("SELECT MAX(position) FROM recurring_items WHERE list_id = ?", listID).Scan(&maxPosition); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } position := maxPosition.Float64 + 1 id, err := a.db.insertReturningID("INSERT INTO recurring_items (list_id, name, sublist_id, position) VALUES (?, ?, ?, ?)", listID, in.Name, in.SublistID, position) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } var ri RecurringItem err = a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ?", id). Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusCreated, ri) } func (a *api) updateRecurring(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") var in struct { Position *float64 `json:"position"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Position != nil { if _, err := a.db.Exec("UPDATE recurring_items SET position = ? WHERE id = ? AND list_id = ?", *in.Position, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } var ri RecurringItem err := a.db.QueryRow("SELECT id, name, sublist_id, position, created_at FROM recurring_items WHERE id = ? AND list_id = ?", id, listID). Scan(&ri.ID, &ri.Name, &ri.SublistID, &ri.Position, &ri.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "recurring item not found") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusOK, ri) } func (a *api) deleteRecurring(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") res, err := a.db.Exec("DELETE FROM recurring_items WHERE id = ? AND list_id = ?", id, listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if n, _ := res.RowsAffected(); n == 0 { writeError(w, http.StatusNotFound, "recurring item not found") return } a.hub.broadcast(listID) w.WriteHeader(http.StatusNoContent) } func (a *api) listSocket(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") exists, err := a.listExists(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !exists { writeError(w, http.StatusNotFound, "list not found") return } // No auth beyond the list id itself, same as the REST endpoints, so any origin may connect. c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) if err != nil { return } defer c.CloseNow() a.hub.add(listID, c) defer a.hub.remove(listID, c) ctx := c.CloseRead(r.Context()) <-ctx.Done() } func (a *api) listItems(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") rows, err := a.db.Query("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE list_id = ? ORDER BY position, id", listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() items := []Item{} for rows.Next() { var it Item if err := rows.Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } items = append(items, it) } writeJSON(w, http.StatusOK, items) } func (a *api) createItem(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") exists, err := a.listExists(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !exists { writeError(w, http.StatusNotFound, "list not found") return } var in struct { Name string `json:"name"` Quantity int `json:"quantity"` SublistID *int64 `json:"sublist_id"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if in.Quantity <= 0 { in.Quantity = 1 } var maxPosition sql.NullFloat64 if err := a.db.QueryRow("SELECT MAX(position) FROM items WHERE list_id = ?", listID).Scan(&maxPosition); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } position := maxPosition.Float64 + 1 id, err := a.db.insertReturningID("INSERT INTO items (list_id, name, quantity, position, sublist_id) VALUES (?, ?, ?, ?, ?)", listID, in.Name, in.Quantity, position, in.SublistID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } var it Item err = a.db.QueryRow("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE id = ?", id). Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusCreated, it) } func (a *api) updateItem(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") var in struct { Name *string `json:"name"` Quantity *int `json:"quantity"` Done *bool `json:"done"` Position *float64 `json:"position"` SublistID *int64 `json:"sublist_id"` MoveSublist bool `json:"move_sublist"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if in.Name != nil { if _, err := a.db.Exec("UPDATE items SET name = ? WHERE id = ? AND list_id = ?", *in.Name, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } if in.Quantity != nil { if _, err := a.db.Exec("UPDATE items SET quantity = ? WHERE id = ? AND list_id = ?", *in.Quantity, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } if in.Done != nil { if _, err := a.db.Exec("UPDATE items SET done = ? WHERE id = ? AND list_id = ?", *in.Done, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } if in.Position != nil { if _, err := a.db.Exec("UPDATE items SET position = ? WHERE id = ? AND list_id = ?", *in.Position, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } if in.MoveSublist { if _, err := a.db.Exec("UPDATE items SET sublist_id = ? WHERE id = ? AND list_id = ?", in.SublistID, id, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } var it Item err := a.db.QueryRow("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE id = ? AND list_id = ?", id, listID). Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "item not found") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } a.hub.broadcast(listID) writeJSON(w, http.StatusOK, it) } func (a *api) deleteItem(w http.ResponseWriter, r *http.Request) { listID := r.PathValue("listId") id := r.PathValue("id") res, err := a.db.Exec("DELETE FROM items WHERE id = ? AND list_id = ?", id, listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if n, _ := res.RowsAffected(); n == 0 { writeError(w, http.StatusNotFound, "item not found") return } a.hub.broadcast(listID) w.WriteHeader(http.StatusNoContent) }