package api import ( "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/lerko/nib/internal/db" ) type CreateEntityRequest struct { Body string `json:"body"` Title *string `json:"title"` Description *string `json:"description"` Glyph *string `json:"glyph"` TimeAnchor *string `json:"time_anchor"` Tags []string `json:"tags"` CardType *string `json:"card_type"` CardData *string `json:"card_data"` } type UpdateEntityRequest struct { Body *string `json:"body"` Title *string `json:"title"` Description *string `json:"description"` Glyph *string `json:"glyph"` TimeAnchor *string `json:"time_anchor"` Tags *[]string `json:"tags"` Pinned *bool `json:"pinned"` CardType *string `json:"card_type"` CardData *string `json:"card_data"` } type PromoteRequest struct { CardType string `json:"card_type"` CardData *string `json:"card_data"` } func listEntities(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := db.DefaultListParams() if tag := r.URL.Query().Get("tag"); tag != "" { p.Tag = &tag } if date := r.URL.Query().Get("date"); date != "" { if _, err := time.Parse("2006-01-02", date); err != nil { writeError(w, http.StatusBadRequest, "invalid_input", "bad date format, use YYYY-MM-DD") return } p.Date = &date } if from := r.URL.Query().Get("from"); from != "" { if _, err := time.Parse("2006-01-02", from); err != nil { writeError(w, http.StatusBadRequest, "invalid_input", "bad from format, use YYYY-MM-DD") return } p.From = &from } if to := r.URL.Query().Get("to"); to != "" { if _, err := time.Parse("2006-01-02", to); err != nil { writeError(w, http.StatusBadRequest, "invalid_input", "bad to format, use YYYY-MM-DD") return } p.To = &to } if r.URL.Query().Get("cards_only") == "true" { p.CardsOnly = true } if r.URL.Query().Get("include_deleted") == "true" { p.IncludeDeleted = true } if sort := r.URL.Query().Get("sort"); sort != "" { if sort != "created" && sort != "use_count" { writeError(w, http.StatusBadRequest, "invalid_input", "sort must be 'created' or 'use_count'") return } p.Sort = sort } if order := r.URL.Query().Get("order"); order != "" { if order != "asc" && order != "desc" { writeError(w, http.StatusBadRequest, "invalid_input", "order must be 'asc' or 'desc'") return } p.Order = order } if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 { writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer") return } p.Limit = limit } if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { offset, err := strconv.Atoi(offsetStr) if err != nil || offset < 0 { writeError(w, http.StatusBadRequest, "invalid_input", "offset must be a non-negative integer") return } p.Offset = offset } entities, err := store.List(p) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } resp := make([]EntityResponse, len(entities)) for i, e := range entities { resp[i] = entityToResponse(e) } writeJSON(w, http.StatusOK, resp) } } func createEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req CreateEntityRequest if !decodeJSON(w, r, &req) { return } if req.Body == "" && req.Title == nil { writeError(w, http.StatusBadRequest, "invalid_input", "body or title is required") return } glyph := db.GlyphNote if req.Glyph != nil { if !db.ValidGlyph(*req.Glyph) { writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value") return } glyph = db.Glyph(*req.Glyph) } e := &db.Entity{ Body: req.Body, Title: req.Title, Description: req.Description, Glyph: glyph, TimeAnchor: req.TimeAnchor, Tags: req.Tags, } if req.CardType != nil { if !db.ValidCardType(*req.CardType) { writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") return } ct := db.CardType(*req.CardType) e.CardType = &ct e.CardData = req.CardData } if err := store.Create(e); err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusCreated, entityToResponse(e)) } } func getEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") e, err := store.Get(id) if err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } } func updateEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req UpdateEntityRequest if !decodeJSON(w, r, &req) { return } u := &db.EntityUpdate{} u.Body = req.Body u.Title = req.Title u.Description = req.Description u.Tags = req.Tags u.Pinned = req.Pinned u.CardData = req.CardData if req.Glyph != nil { if !db.ValidGlyph(*req.Glyph) { writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value") return } g := db.Glyph(*req.Glyph) u.Glyph = &g } if req.TimeAnchor != nil { u.TimeAnchor = req.TimeAnchor } if req.CardType != nil { if !db.ValidCardType(*req.CardType) { writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") return } ct := db.CardType(*req.CardType) u.CardType = &ct } if err := store.Update(id, u); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } e, err := store.Get(id) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } } type DeleteResponse struct { Result string `json:"result"` } func deleteEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") result, err := store.SoftDelete(id) if err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } label := "soft" if result == db.DeletedHard { label = "hard" } writeJSON(w, http.StatusOK, DeleteResponse{Result: label}) } } func promoteEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req PromoteRequest if !decodeJSON(w, r, &req) { return } if req.CardType == "" { writeError(w, http.StatusBadRequest, "invalid_input", "card_type is required") return } if !db.ValidCardType(req.CardType) { writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") return } if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } if err == db.ErrAlreadyPromoted { writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } e, err := store.Get(id) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } } func demoteEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := store.Demote(id); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } if err == db.ErrAlreadyFluid { writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } e, err := store.Get(id) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } } type AbsorbRequest struct { SourceID string `json:"source_id"` } func absorbEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req AbsorbRequest if !decodeJSON(w, r, &req) { return } if req.SourceID == "" { writeError(w, http.StatusBadRequest, "invalid_input", "source_id is required") return } if req.SourceID == id { writeError(w, http.StatusBadRequest, "invalid_input", "target and source must be different entities") return } if err := store.Absorb(id, req.SourceID); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "target or source entity not found") return } if err == db.ErrTargetCrystallized { writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first") return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } e, err := store.Get(id) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } } func useEntity(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := store.IncrementUse(id); err != nil { if err == db.ErrNotFound { writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } e, err := store.Get(id) if err != nil { writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, entityToResponse(e)) } }