initial vibes
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /skeps-backend .
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
COPY --from=build /skeps-backend /app/skeps-backend
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/skeps-backend"]
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const sqliteSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sublists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id),
|
||||
name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
sublist_id INTEGER REFERENCES sublists(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const mysqlSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sublists (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
list_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
color VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (list_id) REFERENCES lists(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
list_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
position DOUBLE NOT NULL DEFAULT 0,
|
||||
sublist_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (list_id) REFERENCES lists(id),
|
||||
FOREIGN KEY (sublist_id) REFERENCES sublists(id)
|
||||
);`
|
||||
|
||||
func openDB(databaseURL string) (*sql.DB, error) {
|
||||
driver, dsn, found := strings.Cut(databaseURL, "://")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("DATABASE_URL must be in the form driver://dsn, got %q", databaseURL)
|
||||
}
|
||||
|
||||
var schema string
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
schema = sqliteSchema
|
||||
case "mysql":
|
||||
driver = "mysql"
|
||||
schema = mysqlSchema
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database driver %q, expected sqlite or mysql", driver)
|
||||
}
|
||||
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connecting to database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("running migration: %w", err)
|
||||
}
|
||||
|
||||
// Best-effort: adds columns for databases created before they existed.
|
||||
// Fails harmlessly if a column is already there.
|
||||
db.Exec("ALTER TABLE items ADD COLUMN position REAL NOT NULL DEFAULT 0")
|
||||
db.Exec("ALTER TABLE items ADD COLUMN sublist_id INTEGER")
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
module skeps-backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,340 @@
|
||||
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 *sql.DB
|
||||
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
|
||||
}
|
||||
|
||||
res, err := a.db.Exec("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, in.Name, in.Color)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
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
|
||||
|
||||
res, err := a.db.Exec("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
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
conns map[string]map[*websocket.Conn]struct{}
|
||||
}
|
||||
|
||||
func newHub() *hub {
|
||||
return &hub{conns: make(map[string]map[*websocket.Conn]struct{})}
|
||||
}
|
||||
|
||||
func (h *hub) add(listID string, c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.conns[listID] == nil {
|
||||
h.conns[listID] = make(map[*websocket.Conn]struct{})
|
||||
}
|
||||
h.conns[listID][c] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *hub) remove(listID string, c *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.conns[listID], c)
|
||||
if len(h.conns[listID]) == 0 {
|
||||
delete(h.conns, listID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hub) broadcast(listID string) {
|
||||
h.mu.Lock()
|
||||
conns := make([]*websocket.Conn, 0, len(h.conns[listID]))
|
||||
for c := range h.conns[listID] {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range conns {
|
||||
go c.Write(context.Background(), websocket.MessageText, []byte("changed"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
databaseURL = "sqlite://./skeps.db"
|
||||
}
|
||||
|
||||
db, err := openDB(databaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
a := &api{db: db, hub: newHub()}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/lists", a.createList)
|
||||
mux.HandleFunc("GET /api/lists/{listId}", a.getList)
|
||||
mux.HandleFunc("GET /api/lists/{listId}/ws", a.listSocket)
|
||||
mux.HandleFunc("GET /api/lists/{listId}/sublists", a.listSublists)
|
||||
mux.HandleFunc("POST /api/lists/{listId}/sublists", a.createSublist)
|
||||
mux.HandleFunc("GET /api/lists/{listId}/items", a.listItems)
|
||||
mux.HandleFunc("POST /api/lists/{listId}/items", a.createItem)
|
||||
mux.HandleFunc("PATCH /api/lists/{listId}/items/{id}", a.updateItem)
|
||||
mux.HandleFunc("DELETE /api/lists/{listId}/items/{id}", a.deleteItem)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
log.Printf("skeps backend listening on :%s", port)
|
||||
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
var words = []string{
|
||||
"lion", "tiger", "eagle", "falcon", "otter", "badger", "raven", "heron", "panther", "wolf",
|
||||
"cobra", "viper", "shark", "whale", "dolphin", "lynx", "puma", "hawk", "sparrow", "robin",
|
||||
"maple", "cedar", "willow", "birch", "aspen", "pine", "oak", "fern", "moss", "ivy",
|
||||
"river", "canyon", "meadow", "valley", "summit", "glacier", "desert", "forest", "harbor", "island",
|
||||
"comet", "meteor", "nebula", "galaxy", "eclipse", "aurora", "horizon", "zenith", "orbit", "cosmos",
|
||||
"amber", "coral", "jade", "onyx", "opal", "pearl", "ruby", "topaz", "quartz", "granite",
|
||||
"fancy", "bright", "quiet", "swift", "brave", "gentle", "clever", "curious", "eager", "fierce",
|
||||
"golden", "silver", "crimson", "azure", "violet", "scarlet", "emerald", "ivory", "copper", "bronze",
|
||||
"sharpness", "wisdom", "courage", "harmony", "clarity", "serenity", "wonder", "spirit", "journey", "rhythm",
|
||||
"lantern", "compass", "anchor", "beacon", "voyage", "bridge", "castle", "cottage", "orchard", "meadowlark",
|
||||
"thunder", "breeze", "frost", "ember", "blossom", "shadow", "whisper", "ripple", "spark", "drift",
|
||||
"velvet", "marble", "linen", "cotton", "flannel", "satin", "canvas", "denim", "wool", "silk",
|
||||
"cherry", "walnut", "hazel", "juniper", "olive", "cypress", "sequoia", "laurel", "magnolia", "poppy",
|
||||
}
|
||||
|
||||
func generateSlug() string {
|
||||
return words[rand.IntN(len(words))] + "-" + words[rand.IntN(len(words))] + "-" + words[rand.IntN(len(words))]
|
||||
}
|
||||
Reference in New Issue
Block a user