339 lines
9.2 KiB
Go
339 lines
9.2 KiB
Go
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 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)
|
|
}
|
|
|
|
func (a *api) listSublists(w http.ResponseWriter, r *http.Request) {
|
|
listID := r.PathValue("listId")
|
|
|
|
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) 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)
|
|
}
|