Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
298 lines
8.0 KiB
Go
298 lines
8.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/memory"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
// MemoryHandler provides REST endpoints for memory management.
|
|
type MemoryHandler struct {
|
|
stores store.Stores
|
|
compactor *memory.Compactor
|
|
}
|
|
|
|
// NewMemoryHandler creates a new memory handler.
|
|
func NewMemoryHandler(stores store.Stores) *MemoryHandler {
|
|
return &MemoryHandler{stores: stores}
|
|
}
|
|
|
|
// SetCompactor wires the compactor for the /compact endpoint.
|
|
func (h *MemoryHandler) SetCompactor(c *memory.Compactor) {
|
|
h.compactor = c
|
|
}
|
|
|
|
// ListMyMemories returns the current user's memories with optional filters.
|
|
// GET /api/v1/memories?status=active&query=...
|
|
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
status := c.DefaultQuery("status", "active")
|
|
query := c.Query("query")
|
|
|
|
memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{
|
|
Scope: models.MemoryScopeUser,
|
|
OwnerID: userID,
|
|
Status: status,
|
|
Query: query,
|
|
Limit: 100,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if memories == nil {
|
|
memories = []models.Memory{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": memories})
|
|
}
|
|
|
|
// UpdateMemory updates a memory's key/value/confidence.
|
|
// PUT /api/v1/memories/:id
|
|
func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
memoryID := c.Param("id")
|
|
|
|
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
|
return
|
|
}
|
|
|
|
// Ownership check: user can only edit their own user-scope memories
|
|
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Key *string `json:"key"`
|
|
Value *string `json:"value"`
|
|
Confidence *float64 `json:"confidence"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
|
|
if body.Key != nil {
|
|
existing.Key = *body.Key
|
|
}
|
|
if body.Value != nil {
|
|
existing.Value = *body.Value
|
|
}
|
|
if body.Confidence != nil {
|
|
existing.Confidence = *body.Confidence
|
|
}
|
|
|
|
if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"memory": existing})
|
|
}
|
|
|
|
// DeleteMemory hard-deletes a memory.
|
|
// DELETE /api/v1/memories/:id
|
|
func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
memoryID := c.Param("id")
|
|
|
|
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
|
return
|
|
}
|
|
|
|
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// ApproveMemory transitions a pending_review memory to active.
|
|
// POST /api/v1/memories/:id/approve
|
|
func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
|
|
memoryID := c.Param("id")
|
|
|
|
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
|
return
|
|
}
|
|
|
|
if existing.Status != models.MemoryStatusPendingReview {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"})
|
|
return
|
|
}
|
|
|
|
existing.Status = models.MemoryStatusActive
|
|
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"memory": existing})
|
|
}
|
|
|
|
// RejectMemory archives a pending_review memory.
|
|
// POST /api/v1/memories/:id/reject
|
|
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
memoryID := c.Param("id")
|
|
|
|
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
|
|
return
|
|
}
|
|
|
|
// Ownership check: user can only reject their own user-scope memories
|
|
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
|
|
return
|
|
}
|
|
|
|
if existing.Status != models.MemoryStatusPendingReview {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"rejected": true})
|
|
}
|
|
|
|
// MemoryCount returns active + pending counts for the current user.
|
|
// GET /api/v1/memories/count
|
|
func (h *MemoryHandler) MemoryCount(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
ctx := c.Request.Context()
|
|
|
|
active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Count pending by listing
|
|
pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{
|
|
Scope: models.MemoryScopeUser,
|
|
OwnerID: userID,
|
|
Status: models.MemoryStatusPendingReview,
|
|
Limit: 1000,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"active": active,
|
|
"pending": len(pending),
|
|
})
|
|
}
|
|
|
|
// ── Admin Endpoints ──────────────────────────
|
|
|
|
// ListPendingReview returns all pending_review memories (admin only).
|
|
// GET /api/v1/admin/memories/pending
|
|
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if memories == nil {
|
|
memories = []models.Memory{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": memories})
|
|
}
|
|
|
|
// BulkApprove approves multiple memories at once.
|
|
// POST /api/v1/admin/memories/bulk-approve
|
|
func (h *MemoryHandler) BulkApprove(c *gin.Context) {
|
|
var body struct {
|
|
IDs []string `json:"ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
approved := 0
|
|
for _, id := range body.IDs {
|
|
existing, err := h.stores.Memories.GetByID(ctx, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if existing.Status != models.MemoryStatusPendingReview {
|
|
continue
|
|
}
|
|
existing.Status = models.MemoryStatusActive
|
|
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
|
|
approved++
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"approved": approved})
|
|
}
|
|
|
|
// CompactMemories triggers memory compaction for the current user.
|
|
// POST /api/v1/memories/compact
|
|
func (h *MemoryHandler) CompactMemories(c *gin.Context) {
|
|
if h.compactor == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"})
|
|
return
|
|
}
|
|
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
teamID := c.GetString("team_id")
|
|
var tID *string
|
|
if teamID != "" {
|
|
tID = &teamID
|
|
}
|
|
|
|
result, err := h.compactor.Compact(c.Request.Context(), userID, tID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"before": result.Before,
|
|
"after": result.After,
|
|
"merged": result.Merged,
|
|
"archived": result.Archived,
|
|
})
|
|
}
|