package httpapi import ( "fmt" "log/slog" "net/http" "strconv" "github.com/google/uuid" "github.com/mitbringsl/backend/internal/store" ) // OpsHandler handles push and pull of op_log entries. type OpsHandler struct { ops *store.OpStore lists *store.ListStore } // NewOpsHandler creates an OpsHandler with the given stores. func NewOpsHandler(ops *store.OpStore, lists *store.ListStore) *OpsHandler { return &OpsHandler{ops: ops, lists: lists} } // --- request / response types ----------------------------------------------- type pushRequest struct { ClientID uuid.UUID `json:"client_id"` Ops []store.IncomingOp `json:"ops"` } type pushResponse struct { Results []store.OpResult `json:"results"` } type pullResponse struct { Ops []store.Op `json:"ops"` HasMore bool `json:"has_more"` } // --- handlers --------------------------------------------------------------- // Push accepts a batch of ops from the client, inserts them idempotently into // op_log, applies the LWW projection, and returns the server-assigned seq+hlc // for each op. // // POST /api/lists/{id}/ops func (h *OpsHandler) Push(w http.ResponseWriter, r *http.Request) { userID, ok := userIDFromCtx(r) if !ok { renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.") return } listID, err := uuid.Parse(r.PathValue("id")) if err != nil { renderError(w, http.StatusBadRequest, "Bad request", "Invalid list ID.") return } // Verify the user owns (or is a member of) the list. if _, err := h.lists.GetList(r.Context(), listID, userID); err != nil { if !apiError(w, err) { renderError(w, http.StatusNotFound, "Not found", "List not found.") } return } var req pushRequest if !decodeJSON(w, r, &req) { return } if req.ClientID == uuid.Nil { renderError(w, http.StatusBadRequest, "Bad request", "client_id must be a non-nil UUID.") return } if len(req.Ops) == 0 { renderJSON(w, http.StatusOK, pushResponse{Results: []store.OpResult{}}) return } maxBatch := store.MaxPushBatch() if len(req.Ops) > maxBatch { renderError(w, http.StatusBadRequest, "Bad request", fmt.Sprintf("Too many ops in a single request (max %d).", maxBatch)) return } results, err := h.ops.AppendOps(r.Context(), listID, userID, req.ClientID, req.Ops) if err != nil { slog.Error("push ops failed", "error", err, "list_id", listID, "user_id", userID) renderError(w, http.StatusInternalServerError, "Internal error", "Could not store ops.") return } renderJSON(w, http.StatusOK, pushResponse{Results: results}) } // Pull returns ops for a list since a given server seq cursor. // // GET /api/lists/{id}/ops?since=0 func (h *OpsHandler) Pull(w http.ResponseWriter, r *http.Request) { userID, ok := userIDFromCtx(r) if !ok { renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.") return } listID, err := uuid.Parse(r.PathValue("id")) if err != nil { renderError(w, http.StatusBadRequest, "Bad request", "Invalid list ID.") return } // Verify list access. if _, err := h.lists.GetList(r.Context(), listID, userID); err != nil { if !apiError(w, err) { renderError(w, http.StatusNotFound, "Not found", "List not found.") } return } since := int64(0) if s := r.URL.Query().Get("since"); s != "" { v, err := strconv.ParseInt(s, 10, 64) if err != nil || v < 0 { renderError(w, http.StatusBadRequest, "Bad request", "since must be a non-negative integer.") return } since = v } ops, err := h.ops.PullOps(r.Context(), listID, since) if err != nil { slog.Error("pull ops failed", "error", err, "list_id", listID, "since", since) renderError(w, http.StatusInternalServerError, "Internal error", "Could not load ops.") return } if ops == nil { ops = []store.Op{} } // has_more is true when we returned exactly the page-size limit. // The client should pull again with the last returned seq. hasMore := len(ops) == 500 // pullPageSize is 500 in opstore.go renderJSON(w, http.StatusOK, pullResponse{Ops: ops, HasMore: hasMore}) }