package main import ( "database/sql" "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" ) //go:embed frontend/dist var staticFiles embed.FS // ==== Models ==== type Checklist struct { UUID string `json:"uuid"` Name string `json:"name"` } type ChecklistItem struct { ID int `json:"id"` Content string `json:"content"` Checked bool `json:"checked"` ParentID *int `json:"parent_id"` LockedBy *string `json:"locked_by,omitempty"` LockUntil *time.Time `json:"lock_until,omitempty"` Checklist string `json:"checklist_uuid"` } type ItemLock struct { LockedBy string Expires time.Time ChecklistUUID string } // ==== Globals ==== var ( db *sql.DB sseClients = make(map[string]map[chan string]bool) // checklist uuid → set of client channels sseClientsMutex sync.Mutex itemLocks = make(map[int]*ItemLock) // item ID → lock itemLocksMutex sync.Mutex ) // ==== Database ==== func setupDatabase() error { var err error db, err = sql.Open("sqlite3", "data/checklists.db") if err != nil { return err } queries := []string{ `CREATE TABLE IF NOT EXISTS checklists ( uuid TEXT PRIMARY KEY, name TEXT NOT NULL );`, `CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, checked INTEGER NOT NULL, parent_id INTEGER, checklist_uuid TEXT NOT NULL, FOREIGN KEY(parent_id) REFERENCES items(id), FOREIGN KEY(checklist_uuid) REFERENCES checklists(uuid) );`, } for _, q := range queries { if _, err := db.Exec(q); err != nil { return err } } return nil } func loadChecklistName(uuid string) (string, error) { rows, err := db.Query( `SELECT name FROM checklists WHERE uuid = ?`, uuid) if err != nil { return "", err } defer rows.Close() if rows.Next() { var name string err = rows.Scan(&name) if err != nil { return "", err } return name, nil } return "", fmt.Errorf("not found") } func loadChecklistItems(uuid string) ([]ChecklistItem, error) { rows, err := db.Query( `SELECT id, content, checked, parent_id FROM items WHERE checklist_uuid = ?`, uuid) if err != nil { return nil, err } defer rows.Close() var items []ChecklistItem for rows.Next() { var it ChecklistItem var checked int var parentID sql.NullInt64 err = rows.Scan(&it.ID, &it.Content, &checked, &parentID) if err != nil { return nil, err } it.Checked = checked != 0 if parentID.Valid { v := int(parentID.Int64) it.ParentID = &v } it.Checklist = uuid // Attach lock info if present itemLocksMutex.Lock() if lock, ok := itemLocks[it.ID]; ok && lock.Expires.After(time.Now()) { it.LockedBy = &lock.LockedBy t := lock.Expires it.LockUntil = &t } itemLocksMutex.Unlock() items = append(items, it) } return items, nil } func addChecklist(name string) (string, error) { uuidStr := uuid.New().String() _, err := db.Exec(`INSERT INTO checklists (uuid, name) VALUES (?, ?)`, uuidStr, name) return uuidStr, err } func addItem(uuid, content string, parentID *int) (ChecklistItem, error) { res, err := db.Exec(`INSERT INTO items (content, checked, parent_id, checklist_uuid) VALUES (?, 0, ?, ?)`, content, parentID, uuid) if err != nil { return ChecklistItem{}, err } id, _ := res.LastInsertId() return ChecklistItem{ ID: int(id), Content: content, Checked: false, ParentID: parentID, Checklist: uuid, }, nil } func updateItem(uuid string, id int, content *string, checked *bool, parentID *int) (ChecklistItem, error) { q := "UPDATE items SET " args := []interface{}{} set := []string{} if content != nil { set = append(set, "content = ?") args = append(args, *content) } if checked != nil { set = append(set, "checked = ?") if *checked { args = append(args, 1) } else { args = append(args, 0) } } if parentID != nil { set = append(set, "parent_id = ?") args = append(args, *parentID) } q += strings.Join(set, ", ") + " WHERE id = ? AND checklist_uuid = ?" args = append(args, id, uuid) _, err := db.Exec(q, args...) if err != nil { return ChecklistItem{}, err } // Return updated item rows, err := db.Query( `SELECT id, content, checked, parent_id FROM items WHERE id = ? AND checklist_uuid = ?`, id, uuid) if err != nil { return ChecklistItem{}, err } defer rows.Close() if rows.Next() { var it ChecklistItem var checkedInt int var parentID sql.NullInt64 err = rows.Scan(&it.ID, &it.Content, &checkedInt, &parentID) if err != nil { return ChecklistItem{}, err } it.Checked = checkedInt != 0 if parentID.Valid { v := int(parentID.Int64) it.ParentID = &v } it.Checklist = uuid return it, nil } return ChecklistItem{}, fmt.Errorf("not found") } func deleteItem(uuid string, id int) error { _, err := db.Exec(`DELETE FROM items WHERE id = ? AND checklist_uuid = ?`, id, uuid) return err } // ==== SSE Broadcast Logic ==== func broadcast(uuid string, msg interface{}) { js, _ := json.Marshal(msg) sseClientsMutex.Lock() for ch := range sseClients[uuid] { select { case ch <- string(js): default: // skip if channel is full (consider logging in prod!) } } sseClientsMutex.Unlock() } // ==== HTTP Handlers ==== func handleGetItems(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] items, err := loadChecklistItems(uuid) if err != nil { http.Error(w, "Failed to load items", 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Items loaded successfully", "items": items, }) } func handleCreateChecklist(w http.ResponseWriter, r *http.Request) { type Req struct { Name string `json:"name"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { http.Error(w, "Missing name", 400) return } uuid, err := addChecklist(req.Name) if err != nil { http.Error(w, "Failed to create checklist", 500) return } w.Header().Set("Content-Type", "application/json") resp := map[string]interface{}{ "success": true, "message": "Checklist created successfully", "uuid": uuid, } json.NewEncoder(w).Encode(resp) } func handleAddItem(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] type Req struct { Content string `json:"content"` ParentID *int `json:"parent_id"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Content) == "" { http.Error(w, "Missing content", 400) return } item, err := addItem(uuid, req.Content, req.ParentID) if err != nil { http.Error(w, "Failed to add item", 500) return } // broadcast broadcast(uuid, map[string]interface{}{"type": "item_added", "item": item}) w.Header().Set("Content-Type", "application/json") w.WriteHeader(201) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item added successfully", "item": item, }) } func handleUpdateItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) type Req struct { Content *string `json:"content"` Checked *bool `json:"checked"` ParentID *int `json:"parent_id"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Bad body", 400) return } item, err := updateItem(uuid, id, req.Content, req.Checked, req.ParentID) if err != nil { http.Error(w, "Not found", 404) return } broadcast(uuid, map[string]interface{}{"type": "item_updated", "item": item}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item updated successfully", "item": item, }) } func handleDeleteItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) if err := deleteItem(uuid, id); err != nil { http.Error(w, "Delete failed", 500) return } broadcast(uuid, map[string]interface{}{"type": "item_deleted", "id": id}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item deleted successfully", "id": id, }) } func handleLockItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) type Req struct { User string `json:"user"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.User) == "" { http.Error(w, "Missing user", 400) return } expiry := time.Now().Add(15 * time.Second) // e.g. 30 sec lock itemLocksMutex.Lock() itemLocks[id] = &ItemLock{LockedBy: req.User, Expires: expiry, ChecklistUUID: uuid} itemLocksMutex.Unlock() // Broadcast lock broadcast(uuid, map[string]interface{}{"type": "item_locked", "id": id, "locked_by": req.User, "expires": expiry}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item locked successfully", "id": id, "locked_by": req.User, "expires": expiry, }) } func lockExpiryDaemon() { for { time.Sleep(1 * time.Second) now := time.Now() // Collect expired locks first var expiredLocks []struct { id int uuid string } itemLocksMutex.Lock() for id, lock := range itemLocks { if lock.Expires.Before(now) { expiredLocks = append(expiredLocks, struct { id int uuid string }{id: id, uuid: lock.ChecklistUUID}) delete(itemLocks, id) } } itemLocksMutex.Unlock() // Broadcast unlock events after releasing the mutex for _, expired := range expiredLocks { broadcast(expired.uuid, map[string]interface{}{ "type": "item_unlocked", "id": expired.id, }) } } } func handleSSE(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ch := make(chan string, 20) // Register client sseClientsMutex.Lock() if sseClients[uuid] == nil { sseClients[uuid] = make(map[chan string]bool) } sseClients[uuid][ch] = true sseClientsMutex.Unlock() defer func() { sseClientsMutex.Lock() delete(sseClients[uuid], ch) sseClientsMutex.Unlock() close(ch) }() // Send full state on connect items, err := loadChecklistItems(uuid) name, err2 := loadChecklistName(uuid) if err == nil && err2 == nil { msg, _ := json.Marshal(map[string]interface{}{ "type": "full_state", "items": items, }) fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() msg, _ = json.Marshal(map[string]interface{}{ "type": "checklist_name", "name": name, }) fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() } // Forward events for { select { case ev := <-ch: fmt.Fprintf(w, "data: %s\n\n", ev) flusher.Flush() case <-r.Context().Done(): return } } } // ==== Main + Routing ==== func main() { if err := setupDatabase(); err != nil { log.Fatalf("DB setup: %v", err) } go lockExpiryDaemon() // Serve static files from embedded filesystem http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Check if this is a UUID path (starts with / followed by 36 characters) isUUIDPath := len(r.URL.Path) == 37 && r.URL.Path[0] == '/' && !strings.Contains(r.URL.Path[1:], "/") if r.URL.Path == "/" || isUUIDPath { // Serve index.html at root or for routes with /{uuid} content, err := staticFiles.ReadFile("frontend/dist/index.html") if err != nil { http.Error(w, "Not found", 404) return } w.Header().Set("Content-Type", "text/html") w.Write(content) } else { // Serve other static files from the static/ subdirectory subFS, err := fs.Sub(staticFiles, "frontend/dist") if err != nil { http.Error(w, "Not found", 404) return } http.StripPrefix("/", http.FileServer(http.FS(subFS))).ServeHTTP(w, r) } }) http.HandleFunc("/api/checklists", func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { handleCreateChecklist(w, r) } else { http.NotFound(w, r) } }) http.HandleFunc("/api/checklists/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { case strings.HasSuffix(path, "/items") && r.Method == "GET": handleGetItems(w, r) case strings.HasSuffix(path, "/items") && r.Method == "POST": handleAddItem(w, r) case strings.Contains(path, "/items/") && strings.HasSuffix(path, "/lock") && r.Method == "POST": handleLockItem(w, r) case strings.Contains(path, "/items/") && r.Method == "PATCH": handleUpdateItem(w, r) case strings.Contains(path, "/items/") && r.Method == "DELETE": handleDeleteItem(w, r) case strings.HasSuffix(path, "/sse") && r.Method == "GET": handleSSE(w, r) default: http.NotFound(w, r) } }) port := strings.TrimSpace(os.Getenv("PORT")) if port == "" { port = "8080" } parsedPort, err := strconv.Atoi(port) if err != nil { log.Fatalf("Invalid PORT environment variable: %v", err) } log.Printf("Listening on :%d", parsedPort) log.Printf("Frontend available at: http://localhost:%d", parsedPort) log.Fatal(http.ListenAndServe(":"+port, nil)) }