package dashboardapi import ( "encoding/json" "errors" "io" "net/http" "strconv" "strings" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/correlation" "github.com/itworx/pulse/internal/dashboard" "github.com/itworx/pulse/internal/problem" "github.com/itworx/pulse/internal/widgetpreview" ) type Handler struct { Repository dashboard.Repository Audit audit.Store } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { principal, ok := auth.PrincipalFromContext(r.Context()) if !ok { fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.") return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/dashboards") if path == "" || path == "/" { switch r.Method { case http.MethodGet: h.list(w, r, principal.Subject) case http.MethodPost: if !auth.Allows(principal.Role, auth.PermissionEdit) { fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.") return } h.create(w, r, principal.Subject) default: fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.") } return } parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) == 0 || parts[0] == "" { fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard not found.") return } id := parts[0] if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet { h.versions(w, r, id, principal.Subject) return } if len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodGet { h.version(w, r, id, parts[2], principal.Subject) return } if len(parts) == 2 && parts[1] == "document" && r.Method == http.MethodPut { if !requireEdit(w, r, principal.Role) { return } h.update(w, r, id, principal.Subject) return } if len(parts) == 2 && parts[1] == "preview" && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.preview(w, r, id, principal.Subject) return } if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.clone(w, r, id, principal.Subject) return } if len(parts) == 2 && parts[1] == "restore" && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.restore(w, r, id, principal.Subject, "") return } if len(parts) == 3 && parts[1] == "restore" && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.restore(w, r, id, principal.Subject, parts[2]) return } if len(parts) == 1 && r.Method == http.MethodGet { h.get(w, r, id, principal.Subject) return } if len(parts) == 1 && r.Method == http.MethodPatch { if !requireEdit(w, r, principal.Role) { return } h.metadata(w, r, id, principal.Subject) return } if len(parts) == 1 && r.Method == http.MethodDelete { if !requireEdit(w, r, principal.Role) { return } h.archive(w, r, id, principal.Subject) return } fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard route not found.") } func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool { if auth.Allows(role, auth.PermissionEdit) { return true } fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.") return false } func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) { var doc dashboard.Document if err := decode(r, &doc); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.") return } s, v, err := h.Repository.Create(r.Context(), actor, doc, "initial version") if err != nil { h.repositoryFailure(w, r, err, "Dashboard could not be created.") return } if err := h.record(r, actor, "dashboard.create", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v}) } func (h Handler) list(w http.ResponseWriter, r *http.Request, actor string) { limit := 100 if value := r.URL.Query().Get("limit"); value != "" { parsed, err := strconv.Atoi(value) if err != nil || parsed < 1 || parsed > 100 { fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The dashboard limit must be between 1 and 100.") return } limit = parsed } items, err := h.Repository.List(r.Context(), actor, limit) if err != nil { h.repositoryFailure(w, r, err, "Dashboard list unavailable.") return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) get(w http.ResponseWriter, r *http.Request, id, actor string) { s, v, err := h.Repository.Get(r.Context(), id, actor) if err != nil { h.repositoryFailure(w, r, err, "Dashboard not found.") return } write(w, http.StatusOK, map[string]any{"dashboard": s, "version": v}) } func (h Handler) preview(w http.ResponseWriter, r *http.Request, id, actor string) { allowed, err := h.Repository.CanAccess(r.Context(), id, actor) if err != nil { h.repositoryFailure(w, r, err, "Dashboard preview unavailable.") return } if !allowed { fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot preview this dashboard.") return } var body struct { Widget map[string]any State string } if err := decode(r, &body); err != nil || body.Widget == nil { fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "A widget configuration is required.") return } result, err := widgetpreview.Preview(body.Widget, body.State) if err != nil { var invalid widgetpreview.InvalidConfig if errors.As(err, &invalid) { fail(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "The widget configuration is invalid.", invalid.Fields) return } fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The widget preview is invalid.") return } write(w, http.StatusOK, map[string]any{"preview": result}) } func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) { expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } var doc dashboard.Document if err := decode(r, &doc); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.") return } s, err := h.Repository.UpdateDocument(r.Context(), id, actor, expected, doc, "document update") if err != nil { h.repositoryFailure(w, r, err, "Dashboard update failed.") return } if err := h.record(r, actor, "dashboard.update_document", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"dashboard": s}) } func (h Handler) metadata(w http.ResponseWriter, r *http.Request, id, actor string) { expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } var body struct { Name string Description string } if err := decode(r, &body); err != nil || strings.TrimSpace(body.Name) == "" { fail(w, r, http.StatusBadRequest, "INVALID_METADATA", "A dashboard name is required.") return } s, err := h.Repository.UpdateMetadata(r.Context(), id, actor, expected, body.Name, body.Description) if err != nil { h.repositoryFailure(w, r, err, "Metadata update failed.") return } if err := h.record(r, actor, "dashboard.update_metadata", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"dashboard": s}) } func (h Handler) archive(w http.ResponseWriter, r *http.Request, id, actor string) { expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } s, err := h.Repository.Archive(r.Context(), id, actor, expected) if err != nil { h.repositoryFailure(w, r, err, "Dashboard archive failed.") return } if err := h.record(r, actor, "dashboard.archive", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"dashboard": s}) } func (h Handler) versions(w http.ResponseWriter, r *http.Request, id, actor string) { items, err := h.Repository.Versions(r.Context(), id, actor, 100) if err != nil { h.repositoryFailure(w, r, err, "Version history unavailable.") return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) version(w http.ResponseWriter, r *http.Request, id, value, actor string) { number, err := strconv.Atoi(value) if err != nil || number < 1 { fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.") return } item, err := h.Repository.GetVersion(r.Context(), id, actor, number) if err != nil { h.repositoryFailure(w, r, err, "Dashboard version not found.") return } write(w, http.StatusOK, map[string]any{"version": item}) } func (h Handler) clone(w http.ResponseWriter, r *http.Request, id, actor string) { var body struct { Slug string Name string } if err := decode(r, &body); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_CLONE", "The clone options are invalid.") return } s, v, err := h.Repository.Clone(r.Context(), actor, id, body.Slug, body.Name) if err != nil { h.repositoryFailure(w, r, err, "Dashboard could not be cloned.") return } if err := h.record(r, actor, "dashboard.clone", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v}) } func (h Handler) restore(w http.ResponseWriter, r *http.Request, id, actor, value string) { number := value if number == "" { var body struct{ Version int } if err := decode(r, &body); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "A version number is required.") return } number = strconv.Itoa(body.Version) } versionNumber, err := strconv.Atoi(number) if err != nil || versionNumber < 1 { fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.") return } expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } s, err := h.Repository.Restore(r.Context(), id, actor, expected, versionNumber) if err != nil { h.repositoryFailure(w, r, err, "Dashboard could not be restored.") return } if err := h.record(r, actor, "dashboard.restore", s.ID, map[string]any{"version": versionNumber}, map[string]any{"revision": s.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"dashboard": s}) } func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error { if h.Audit == nil { return nil } return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "dashboard", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after}) } func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) { switch { case errors.Is(err, dashboard.ErrConflict): fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The dashboard was changed by another request.") case errors.Is(err, dashboard.ErrForbidden): fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot change this dashboard.") case errors.Is(err, dashboard.ErrNotFound): fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback) default: fail(w, r, http.StatusBadRequest, "DASHBOARD_REQUEST_FAILED", fallback) } } func decode(r *http.Request, target any) error { contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0])) if contentType != "" && contentType != "application/json" { return errors.New("unsupported content type") } body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1)) if err != nil { return err } defer r.Body.Close() if len(body) > 2<<20 { return errors.New("request too large") } decoder := json.NewDecoder(strings.NewReader(string(body))) if err := decoder.Decode(target); err != nil { return err } var extra any if err := decoder.Decode(&extra); err != io.EOF { return errors.New("multiple JSON values") } return nil } func revision(r *http.Request) (int64, error) { value := r.Header.Get("If-Match") if value == "" { value = r.URL.Query().Get("revision") } return strconv.ParseInt(strings.Trim(value, "\""), 10, 64) } func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string, fields ...map[string]string) { var extra map[string]string if len(fields) > 0 { extra = fields[0] } problem.Write(w, r, status, code, http.StatusText(status), detail, extra) } func write(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }